{"owner":"bluesky-social","repo":"atproto","hasSkills":true,"totalSkillsCount":75,"totalTokensCount":110226,"categories":["anthropic-skill","root-instruction","claude-rule","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":[".agents/skills/lex-client/SKILL.md",".agents/skills/lex-data/SKILL.md",".agents/skills/lex-schema/SKILL.md",".agents/skills/lex-setup/SKILL.md",".agents/skills/lexification-client/SKILL.md",".agents/skills/lexification-server/SKILL.md",".agents/skills/lexification-server/references/project-config.md",".agents/skills/lexification-server/references/routes.md",".agents/skills/lexification-server/references/types-and-guards.md",".agents/skills/playwright/SKILL.md",".agents/skills/testing/SKILL.md",".agents/skills/testing/references/jest.md",".agents/skills/testing/references/vitest.md",".agents/skills/update-chat-lexicons/SKILL.md",".agents/skills/xrpc-server/SKILL.md","AGENTS.md","CLAUDE.md","packages/README.md","packages/api/README.md","packages/api/docs/moderation.md","packages/aws/README.md","packages/bsky/AGENTS.md","packages/bsky/README.md","packages/bsky/src/feature-gates/README.md","packages/bsync/README.md","packages/common-web/README.md","packages/common/README.md","packages/crypto/README.md","packages/dev-env/README.md","packages/dev-infra/README.md","packages/identity/README.md","packages/internal/fetch-node/README.md","packages/internal/handle-resolver/README.md","packages/internal/opentelemetry-node/README.md","packages/internal/rolldown-plugin-bundle-manifest/README.md","packages/lex-cli/README.md","packages/lex/lex-builder/README.md","packages/lex/lex-cbor/README.md","packages/lex/lex-client/README.md","packages/lex/lex-data/README.md","packages/lex/lex-document/README.md","packages/lex/lex-installer/README.md","packages/lex/lex-json/README.md","packages/lex/lex-password-session/README.md","packages/lex/lex-resolver/AGENTS.md","packages/lex/lex-resolver/README.md","packages/lex/lex-rpc/README.md","packages/lex/lex-schema/README.md","packages/lex/lex-server/README.md","packages/lex/lex/README.md","packages/lexicon-resolver/README.md","packages/lexicon/README.md","packages/oauth/oauth-client-browser/README.md","packages/oauth/oauth-client-browser/src/indexed-db/README.md","packages/oauth/oauth-client-expo/README.md","packages/oauth/oauth-client-node/README.md","packages/oauth/oauth-client/README.md","packages/oauth/oauth-provider-ui/CLAUDE.md","packages/oauth/oauth-provider-ui/README.md","packages/oauth/oauth-provider/src/lib/html/README.md","packages/oauth/oauth-provider/src/lib/http/README.md","packages/oauth/oauth-types/README.md","packages/ozone/AGENTS.md","packages/ozone/README.md","packages/pds/README.md","packages/pds/src/scripts/README.md","packages/repo/AGENTS.md","packages/repo/CLAUDE.md","packages/repo/README.md","packages/sync/README.md","packages/syntax/README.md","packages/tap/README.md","packages/ws-client/README.md","packages/xrpc-server/README.md","packages/xrpc/README.md"],"skills":{".agents/skills/lex-client/SKILL.md":"---\nname: lex-client\ndescription: >\n  Call AT Protocol XRPC services with the `Client` from `@atproto/lex`. Use\n  when making an authenticated or unauthenticated HTTP request to an XRPC\n  service, wiring one service to call another, logging in with OAuth or an app\n  password, reading or writing repository records, uploading or fetching blobs,\n  configuring headers, labelers, service proxying, retries, or request/response\n  validation, handling XRPC errors, or composing reusable client actions. Also\n  use when replacing `AtpAgent`, `XrpcClient`, or a hand-rolled `fetch` call to\n  an `/xrpc/` endpoint. For a full migration off the legacy client stack, use\n  the lexification-client skill.\ndisable-model-invocation: false\n---\n\n# high-level XRPC client\n\n`Client` wraps an authenticated (or anonymous) session with helpers for AT Proto\nrepo operations (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`applyWrites`, `uploadBlob`, `getBlob`) plus the low-level\n`call`/`xrpc`/`xrpcSafe` request methods. Reach for it whenever there is a\nsession to carry, or per-service config (headers, labelers, proxy target) worth\nencapsulating.\n\nThe standalone `xrpc()` / `xrpcSafe()` exports exist for one-off, session-less\ncalls. Their first argument is an `Agent | AgentOptions` — a URL string, `URL`,\n`AgentConfig` object, or bare `FetchHandler` — and they otherwise share the same\noptions, responses, and errors as the client methods.\n\n```ts\nimport { xrpc } from '@atproto/lex'\n\nawait xrpc(hostUrl, com.atproto.sync.requestCrawl, { body: { hostname } })\n```\n\n## Constructing\n\nThe constructor takes an `Agent` (anything with `fetchHandler`, optionally\n`did`) or `AgentOptions` (a URL string/`URL`, or an `AgentConfig`).\n\n```ts\nimport { Client } from '@atproto/lex'\n\n// Unauthenticated\nconst client = new Client('https://public.api.bsky.app')\n\n// OAuth — OAuthSession implements Agent\nimport { type OAuthSession } from '@atproto/oauth-client-node'\nconst session: OAuthSession = await oauthClient.restore(userDid)\nconst client = new Client(session)\n```\n\n### App password sessions (CLI / scripts / bots)\n\n`PasswordSession` also implements `Agent`, plus `AsyncDisposable` — prefer\n`await using` so the session is deleted server-side on scope exit.\n\n```ts\nimport {\n  LexAuthFactorError,\n  PasswordSession,\n} from '@atproto/lex-password-session'\n\n// Credentials as a single URL: https://<handle>:<app-password>@<pds-host>\nawait using session = await PasswordSession.login(\n  process.env.APP_PASSWORD_CREDENTIALS,\n)\nconst client = new Client(session)\n```\n\nLong-lived apps should persist session data via `onUpdated` and use\n`PasswordSession.resume(data, options)` on the next run rather than re-sending\ncredentials. Other statics: `createAccount(body, { service, ... })` and\n`delete(data)` (revoke stored session data without resuming it). `login` throws\n`LexAuthFactorError` when the account requires 2FA — catch it, prompt, and retry\nwith `authFactorToken`.\n\n### Server-side calls with a static credential\n\nNo session needed — put the credential in the constructor headers. Internal\nservices typically also relax response processing:\n\n```ts\nconst client = new Client(\n  {\n    service: config.serviceUrl,\n    headers: { authorization: `Bearer ${config.serviceApiKey}` },\n  },\n  // Trust internal services to send well-formed responses\n  { strictResponseProcessing: false, validateResponse: config.devMode },\n)\n```\n\nIf the credential or URL must be resolved per-request, supply a custom agent:\n\n```ts\nconst agent: Agent = {\n  get did() {\n    return undefined\n  },\n  async fetchHandler(path, init) {\n    const { serviceUrl, serviceApiKey } = await getConfig()\n    const headers = new Headers(init?.headers)\n    headers.set('authorization', `Bearer ${serviceApiKey}`)\n    return fetch(new URL(path, serviceUrl), { ...init, headers })\n  },\n}\n\nconst client = new Client(agent)\n```\n\n`AgentConfig` and `fetchHandler` are mutually exclusive — passing both throws a\n`TypeError`.\n\n### Service proxying, and one client for several services\n\n`service` and `labelers` set the `atproto-proxy` / `atproto-accept-labelers`\nheaders. Both are _defaults_ and are overridable per request, so a single\nAppView-configured client can still reach the user's PDS directly — pass `null`\nto suppress the header for that request.\n\n```ts\nconst client = new Client(session, {\n  service: 'did:web:api.bsky.app#bsky_appview',\n  labelers: ['did:plc:labeler1', 'did:plc:labeler2'],\n})\n\n// Proxied to the AppView (client default)\nawait client.call(app.bsky.feed.getTimeline)\n\n// Straight to the user's PDS\nawait client.xrpc(com.atproto.repo.getRecord, {\n  service: null,\n  labelers: null,\n  appLabelers: null,\n  params: { repo: client.assertDid, collection, rkey: 'self' },\n})\n```\n\nThe record helpers (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`*Record`, `applyWrites`, `uploadBlob`, `getBlob`) already default to\n`service: null` / `labelers: null`, since repo operations belong on the PDS. Set\nthem explicitly to proxy one.\n\n## Validation options\n\n```ts\nconst client = new Client(session, {\n  // Validate request bodies against the input schema before sending. Worth\n  // enabling in dev/test to catch schema mistakes at the source.\n  validateRequest: true, // default: false\n\n  // Validate response bodies against the output schema.\n  validateResponse: false, // default: true\n\n  // Strict Lex decoding. `false` accepts legacy blobs, datetimes without\n  // timezones, etc. — see the lex-schema skill.\n  strictResponseProcessing: false, // default: true\n})\n```\n\nAll three are overridable per-call on `call` / `xrpc` / `xrpcSafe`.\n\n## Authentication accessors\n\n```ts\nclient.did // DidString | undefined\nclient.assertAuthenticated() // throws if unauthenticated; narrows client.did\nclient.assertDid // DidString (throws if unauthenticated)\n```\n\n`assertAuthenticated()` is an assertion function, so TypeScript requires the\nreceiver to have an explicit type annotation. `const client = new Client(...)`\n(inferred) will fail with TS2775 — write `const client: Client = new Client(...)`\nor annotate the parameter.\n\n## Requests\n\n### `client.call()` — returns the body, throws on failure\n\n```ts\nimport { app } from './lexicons/index.js'\n\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: 'pfrazee.com',\n})\n\nawait client.call(app.bsky.feed.sendInteractions, { interactions })\n\nconst timeline = await client.call(\n  app.bsky.feed.getTimeline,\n  { limit: 50 },\n  { signal, maxRetries: 3, headers: { 'custom-header': 'value' } },\n)\n```\n\nThe input argument is positional and distinct from the options: for a query it\nis the `params`, for a procedure the body, for an action the input. Methods that\ntake neither can be called with the schema alone, but **the options must never\noccupy the input slot** — `client.call(schema, options)` sends `options` as\nquery parameters and fails validation. Use `client.call(schema, {}, options)`,\nor `client.xrpc(schema, options)`.\n\n### `client.xrpc()` — full response\n\n```ts\nconst response = await client.xrpc(app.bsky.feed.getTimeline, {\n  params: { limit: 50 },\n  signal,\n  headers: { 'custom-header': 'value' },\n})\nresponse.status\nresponse.headers.get('content-language')\nresponse.body\n```\n\nUnlike `call`, `xrpc` takes a single options object with `params` / `body` as\nnamed fields, so options-only calls are unambiguous.\n\n### `client.xrpcSafe()` — discriminated result\n\nSame request as `xrpc()`, but returns the failure instead of throwing:\n\n```ts\nconst result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {\n  params: { handle: 'alice.bsky.social' },\n})\nif (result.success) {\n  result.body\n} else if (result.matchesSchemaErrors()) {\n  result.error // narrowed to the errors declared by the schema\n} else {\n  throw result.reason\n}\n```\n\n### Errors\n\nAll failures extend `XrpcError` (`error`, `message`, `matchesSchemaErrors()`,\n`shouldRetry()`). The three concrete branches of `XrpcFailure`:\n\n| Class                      | Meaning                                                                 |\n| -------------------------- | ----------------------------------------------------------------------- |\n| `XrpcResponseError`        | Server returned a non-2xx status                                        |\n| `XrpcInvalidResponseError` | Response is non-compliant — unexpected status, wrong encoding, bad data |\n| `XrpcInternalError`        | Failed before a usable response existed (network, client)               |\n\nSubclasses worth narrowing on: `XrpcAuthenticationError` (401, exposes\n`wwwAuthenticate`), `XrpcResponseValidationError` (response failed schema\nvalidation), and `XrpcFetchError` (the underlying `fetch` threw — the retryable\ninternal error).\n\nOnly `XrpcResponseError` carries HTTP data. `status`, `headers`, and `body` do\n**not** exist on the `XrpcFailure` union, so narrow first:\n\n```ts\nif (!result.success && result instanceof XrpcResponseError) {\n  result.status\n  result.headers.get('retry-after')\n}\n```\n\n### Retries\n\n`call`, `xrpc`, `xrpcSafe`, and the standalone functions all retry internally.\nSet `maxRetries` (default `0`); tune with `minRetryTimeout` (500ms),\n`maxRetryTimeout` (30s), `retryTimeoutFactor` (2), `retryHeaders` (honour\n`Retry-After` / `RateLimit-Reset`, default `true`), or replace the predicate with\n`retry(failure, { counter })`. The defaults already cover the retryable status\ncodes (408, 425, 429, 5xx) and network errors, so do not wrap calls in a manual\nretry loop.\n\n## Records\n\n### `create` / `get` / `put` / `delete`\n\n```ts\nimport { currentDatetimeString } from '@atproto/lex'\n\nconst { uri, cid } = await client.create(app.bsky.feed.post, {\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\n\n// Literal-key records (profile is rkey 'self') need no rkey\nconst profile = await client.get(app.bsky.actor.profile)\nprofile.value.displayName\n\nconst post = await client.get(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n\nawait client.put(app.bsky.actor.profile, { displayName: 'New Name' })\nawait client.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n```\n\n`rkey` is optional for `literal:` and auto-generated (`tid`) keys and required\notherwise; the type system enforces this, so a missing `rkey` shows up as a\ncompile error rather than a runtime one. Shared options: `validate` (ask the PDS\nto validate), `validateRequest` (validate locally before sending), `swapCommit`\n(optimistic concurrency on the commit CID), and for `put`/`delete` also\n`swapRecord` (on the record's own CID).\n\n### `list` / `listAll` — paginating a collection\n\nListed records are a discriminated union on `valid`: entries that fail schema\nvalidation still come back, with `value` widened to `LexMap`. Check `valid`\nbefore touching typed fields.\n\n```ts\nconst page = await client.list(app.bsky.feed.post, { limit: 50, reverse: true })\nfor (const record of page.records) {\n  if (record.valid) console.log(record.uri, record.value.text)\n}\nif (page.cursor) {\n  await client.list(app.bsky.feed.post, { cursor: page.cursor, limit: 50 })\n}\n```\n\n`listAll()` is the auto-paginating async generator — prefer it over hand-rolling\na cursor loop. It defaults to `maxRetries: 3` since a long walk is far more\nlikely to hit a transient failure:\n\n```ts\nfor await (const record of client.listAll(app.bsky.feed.post)) {\n  if (record.valid) console.log(record.value.text)\n}\n```\n\n### `applyWrites` — atomic batch writes\n\nAll ops succeed or fail together. Options: `repo` (defaults to `client.did`),\n`validate`, `swapCommit`.\n\n```ts\nconst response = await client.applyWrites((op) => [\n  op.create(app.bsky.feed.post, {\n    text: 'Hello!',\n    createdAt: currentDatetimeString(),\n  }),\n  op.update(app.bsky.actor.profile, { displayName: 'Alice' }),\n  op.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' }),\n])\n```\n\n`response.body.results` is **optional**, and is a union in which `deleteResult`\nhas no `uri` or `cid`. Narrow before reading them:\n\n```ts\nfor (const result of response.body.results ?? []) {\n  if (com.atproto.repo.applyWrites.createResult.$isTypeOf(result)) {\n    console.log(result.uri, result.cid)\n  }\n}\n```\n\nThe callback may also be a generator, which reads better for dynamic batches:\n\n```ts\nawait client.applyWrites(function* (op) {\n  for (const rkey of staleRkeys) yield op.delete(app.bsky.feed.post, { rkey })\n})\n```\n\n### Blobs\n\n```ts\nconst { body } = await client.uploadBlob(bytes, { encoding: 'image/png' })\nbody.blob // BlobRef — store this in a record\n\nconst image = await client.getBlob(did, cid)\n```\n\n## Labelers\n\n```ts\n// Process-global defaults, always sent with `;redact`\nClient.configure({ appLabelers: ['did:plc:labeler1'] })\n\n// Per-client, and mutable at runtime\nconst client = new Client(session, { labelers: ['did:plc:labeler3'] })\nclient.addLabelers(['did:plc:labeler4'])\nclient.setLabelers(['did:plc:labeler5'])\nclient.clearLabelers()\n```\n\nPass `appLabelers: null` on a request to drop the process-global ones. The usual\npost-login flow reads the user's preference:\n\n```ts\nconst { preferences } = await client.call(app.bsky.actor.getPreferences)\nconst pref = preferences.findLast(app.bsky.actor.defs.labelersPref.$isTypeOf)\nclient.setLabelers(pref?.labelers.map((l) => l.did) ?? [])\n```\n\n## Actions — composable client operations\n\nAn `Action<Input, Output>` is `(client, input, options) => Output | Promise<Output>`.\n`client.call()` accepts one anywhere a schema fits, so an action is\ninterchangeable with a lexicon method at the call site.\n\n```ts\nimport { type Action, Client, currentDatetimeString } from '@atproto/lex'\nimport { app } from './lexicons/index.js'\n\ntype LikeInput = { uri: AtUriString; cid: CidString }\n\nexport const likePost: Action<LikeInput, CreateOutput> = async (\n  client: Client, // explicit — required by assertAuthenticated() below\n  { uri, cid },\n  options,\n) => {\n  client.assertAuthenticated()\n  return client.create(\n    app.bsky.feed.like,\n    { subject: { uri, cid }, createdAt: currentDatetimeString() },\n    options,\n  )\n}\n\nawait client.call(likePost, { uri, cid })\n```\n\nActions compose — thread `options` through so cancellation propagates:\n\n```ts\nconst upsertPreference: Action<Preference, Preference[]> = async (\n  client,\n  pref,\n  options,\n) => {\n  const { preferences } = await client.call(\n    app.bsky.actor.getPreferences,\n    {},\n    options,\n  )\n  options?.signal?.throwIfAborted()\n  const updated = [...preferences.filter((p) => p.$type !== pref.$type), pref]\n  await client.call(\n    app.bsky.actor.putPreferences,\n    { preferences: updated },\n    options,\n  )\n  return updated\n}\n```\n\nExport actions individually rather than as one object, so consumers tree-shake\nwhat they don't call:\n\n```ts\n// actions.ts\nexport const post: Action</* ... */> = async (c, i, o) => {/* ... */}\nexport const follow: Action</* ... */> = async (c, i, o) => {/* ... */}\n\n// usage\nimport * as actions from './actions.js'\nawait client.call(actions.post, { text: 'Hello!' })\n```\n\nGuidelines: annotate as `Action<Input, Output>` (it contextually types the\nparameters); call `client.assertAuthenticated()` when auth is required;\n`options?.signal?.throwIfAborted()` between long steps; and handle swap-error\nretries here rather than pushing optimistic-concurrency handling onto callers.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  every client method, and `$isTypeOf` / `$build` / `$type`.\n- **[lex-data](../lex-data/SKILL.md)** — blobs, CIDs, branded strings, and\n  datetimes at the call boundary.\n- **[lexification-client](../lexification-client/SKILL.md)** — migrating from\n  `AtpAgent` / `XrpcClient`.\n- **[xrpc-server](../xrpc-server/SKILL.md)** — the other half, when the service\n  also _serves_ XRPC routes.\n- **[lex-setup](../lex-setup/SKILL.md)** — installing lexicons and configuring\n  the codegen that produces `./lexicons/index.js`.\n",".agents/skills/lex-data/SKILL.md":"---\nname: lex-data\ndescription: >\n  Represent, parse, serialize, hash, or convert AT Protocol data values with\n  `@atproto/lex`. Use whenever code touches CIDs, raw bytes, `$link` / `$bytes`\n  JSON, CBOR / DRISL encoding, blob references, or AT Protocol identifier and\n  datetime strings — DIDs, handles, AT URIs, NSIDs, TIDs, record keys,\n  `createdAt` / `indexedAt`. Applies when the ask sounds like \"serialize this\n  record\", \"hash these bytes\", \"why is this datetime rejected\", \"count the\n  characters in this field\", or when replacing `multiformats` CIDs,\n  `new Date().toISOString()`, or hand-rolled base64. Read it before typing a\n  DID, AT URI, or datetime as a bare `string`.\ndisable-model-invocation: false\n---\n\n# Data model: types, CIDs, JSON, CBOR, identifier strings, blobs\n\nThe AT Protocol data model is JSON plus two primitives — **CIDs**\n(content-addressed links) and **bytes** (raw binary) — encodable as JSON or as\nCBOR.\n\n## Which package exports what\n\n`@atproto/lex` re-exports `lex-data`, `lex-json`, `lex-schema` and\n`lex-client`, so a package that already depends on it needs no extra import\npath. `@atproto/lex-cbor` is **not** re-exported — depend on it explicitly.\n\n| Symbols                                                       | Defined in                                              | Reachable via `@atproto/lex` |\n| ------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------- |\n| `LexValue`, `Cid`, blob refs, base64/utf8 helpers, `LexError` | `@atproto/lex-data`                                     | yes                          |\n| `lexParse`, `lexStringify`, `jsonToLex`, `lexToJson`          | `@atproto/lex-json`                                     | yes                          |\n| Identifier & datetime string types + guards                   | `@atproto/syntax`, re-exported by `@atproto/lex-schema` | yes                          |\n| `encode`, `decode`, `decodeAll`, `cidForLex`                  | `@atproto/lex-cbor`                                     | **no**                       |\n\nLow-level packages (`repo`, `common`, `xrpc-server`) import `@atproto/lex-data`\ndirectly to avoid pulling in the client and schema layers.\n\n> [!NOTE]\n> This repo sets `verbatimModuleSyntax: true`, so type-only names need the\n> `type` modifier — `import { type DidString, isDidString } from '@atproto/lex'`.\n> All examples below follow that form.\n\n## Lex value types\n\n```ts\nimport {\n  type LexValue,\n  type TypedLexMap,\n  isLexValue,\n  isTypedLexMap,\n} from '@atproto/lex'\n\n// LexScalar:    number (integers only) | string | boolean | null | Cid | Uint8Array\n// LexValue:     LexScalar | LexValue[] | { [key: string]?: LexValue }\n// LexMap:       { [key: string]?: LexValue }\n// TypedLexMap:  LexMap & { $type: string }\n```\n\n`isLexValue`, `isLexMap`, `isLexArray` and `isLexScalar` take `unknown`;\n`isTypedLexMap` takes a `LexValue`, so narrow with `isLexValue` first when the\ninput is untyped. `isLexValue` walks the whole structure and rejects cycles,\nnon-integer numbers, and non-plain objects (`Date`, class instances,\nfunctions) — it is not a cheap check, so avoid it on hot paths where a schema\nalready validates the value.\n\n## CIDs\n\n`Cid` is an interface, not a class. `@atproto/lex-data` defines it rather than\nre-exporting `multiformats`' `CID`, because that class breaks under\n`node16` / `bundler` module resolution. Treat `CID` and `asMultiformatsCID` as\ndeprecated escape hatches.\n\n```ts\nimport {\n  type Cid,\n  asCid,\n  decodeCid,\n  ifCid,\n  isCid,\n  parseCid,\n  parseCidSafe,\n} from '@atproto/lex'\n\nparseCid(str) // Cid — throws on invalid\nparseCidSafe(str) // Cid | null\ndecodeCid(bytes) // Cid from binary form — throws on invalid\nisCid(value) // type guard\nifCid(value) // Cid | null\nasCid(value) // Cid — throws on invalid\n```\n\nEach accepts a `{ flavor }` option that additionally constrains the CID, which\nis how you enforce that a blob CID really is a blob CID rather than trusting\nthe caller:\n\n- `'raw'` — v1 + raw multicodec (blob content)\n- `'cbor'` — v1 + dag-cbor + sha256 (records, MST nodes)\n- `'dasl'` — either codec, v1 + sha256\n\nFor strings you already hold, `validateCidString(str, options)` returns a\nboolean and also checks the string is the canonical encoding;\n`ensureValidCidString` throws instead.\n\nCreating CIDs — hashing is async because it goes through WebCrypto:\n\n```ts\nimport {\n  cidForCbor,\n  cidForRawBytes,\n  cidForRawHash,\n  isCidForBytes,\n} from '@atproto/lex'\nimport { cidForLex } from '@atproto/lex-cbor'\n\nawait cidForLex(record) // CBOR-encode then hash — the usual entry point\nawait cidForCbor(cborBytes) // hash bytes that are already CBOR\nawait cidForRawBytes(blobBytes) // raw CID for blob content\ncidForRawHash(sha256Digest) // sync, when you already streamed the hash\nawait isCidForBytes(cid, bytes) // verify a CID matches its content\n```\n\nIn Lex JSON, CIDs are encoded as `{ \"$link\": \"bafyrei...\" }`.\n\n## Bytes\n\nBinary data is `Uint8Array`; JSON-encoded as `{ \"$bytes\": \"base64...\" }`.\n`@atproto/lex-data` ships the byte utilities so packages don't add their own\nbase64 dependency: `toBase64` / `fromBase64` (both take an optional\n`'base64' | 'base64url'` alphabet), `utf8ToBase64` / `utf8FromBase64`,\n`utf8FromBytes`, `ui8Concat`, `ui8Equals`, `asUint8Array`, `ifUint8Array`.\n\n## JSON ↔ Lex\n\n| Function       | Direction         | Input           | Output                                |\n| -------------- | ----------------- | --------------- | ------------------------------------- |\n| `lexParse`     | JSON string → Lex | `string`        | `LexValue` (with `Cid`, `Uint8Array`) |\n| `lexStringify` | Lex → JSON string | `LexValue`      | `string`                              |\n| `jsonToLex`    | parsed JSON → Lex | plain JS object | `LexValue`                            |\n| `lexToJson`    | Lex → plain JS    | `LexValue`      | plain JS object                       |\n\n`lexParse<T>()` takes a type parameter, so no `as` cast is needed:\n\n```ts\nimport { lexParse, lexStringify } from '@atproto/lex'\n\nconst sub =\n  lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(json)\nconst out = lexStringify({ ref: someCid, data: someBytes })\n```\n\n`lexParse` and `jsonToLex` accept `{ strict }`, defaulting to **`false`**:\nmalformed `$link` / `$bytes` objects and non-integer numbers pass through\nunchanged rather than throwing. Pass `{ strict: true }` when the input is\nuntrusted and a silently-wrong value would be worse than an exception.\n`lexParseJsonBytes(bytes, options)` is the same parse starting from UTF-8\nbytes.\n\nField-level encode/decode is rarely needed, but note the return contract —\nthese signal failure by returning `undefined`, they do not throw:\n\n```ts\nimport {\n  encodeLexBytes,\n  encodeLexLink,\n  parseLexBytes,\n  parseLexLink,\n} from '@atproto/lex'\n\nparseLexLink({ $link: 'bafy...' }) // Cid | undefined\nparseLexBytes({ $bytes: 'SGVsbG8=' }) // Uint8Array | undefined\nencodeLexLink(someCid) // { $link: '...' }\nencodeLexBytes(new Uint8Array([1, 2, 3])) // { $bytes: '...' }\n```\n\n## CBOR (DRISL)\n\nUse CBOR for repo storage, signed records, CAR files, and event frames —\nanywhere [DRISL](https://dasl.ing/drisl.html) deterministic encoding is\nrequired. Add `@atproto/lex-cbor` as a dependency; it is not reachable through\n`@atproto/lex`.\n\n```ts\nimport { type LexValue } from '@atproto/lex'\nimport { cidForLex, decode, decodeAll, encode } from '@atproto/lex-cbor'\n\nconst bytes: Uint8Array = encode(someLexValue)\nconst value = decode<{ foo: LexValue }>(bytes)\n\n// Concatenated values (CAR blocks, subscription frames)\nfor (const frame of decodeAll(buffer)) {\n  /* … */\n}\n```\n\n`encode` throws on anything the AT data model forbids: non-string map keys,\n`undefined`, and non-integer numbers. That strictness is the point — it is what\nmakes the resulting CID stable.\n\n## Datetime strings\n\n`DatetimeString` is the branded type for AT Protocol datetimes.\n`Date.prototype.toISOString()` is not guaranteed to conform (years outside\n0–9999 serialize with a `±YYYYYY` prefix), so route `Date` → string through\nthe helpers rather than calling `toISOString()` directly:\n\n```ts\nimport {\n  type DatetimeString,\n  asDatetimeString,\n  currentDatetimeString,\n  ifDatetimeString,\n  isDatetimeString,\n  toDatetimeString,\n} from '@atproto/lex'\n\ncurrentDatetimeString() // now\ntoDatetimeString(date) // throws InvalidDatetimeError on an out-of-range date\nasDatetimeString(str) // validates + brands a string, throws on invalid\nisDatetimeString(value) // type guard\nifDatetimeString(value) // DatetimeString | undefined\n```\n\n`isDatetimeStringLenient` accepts ISO-ish strings that the spec rejects (e.g.\nmissing a timezone) — use it when reading legacy records, not when writing.\nFor repairing bad historical `createdAt` values, `normalizeDatetime` /\n`normalizeDatetimeAlways` live in `@atproto/syntax` only; they are not\nre-exported from `@atproto/lex`.\n\nUse `DatetimeString` on record and DB row types so a raw `string` can't drift\nin:\n\n```ts\nimport type { DatetimeString, DidString } from '@atproto/lex'\n\ninterface Row {\n  did: DidString\n  indexedAt: DatetimeString\n}\n```\n\n## Identifier strings\n\nNominal string types keep unvalidated `string`s from being passed where a\nvalidated identifier is expected.\n\n| Type                 | Format                     | Guard                  |\n| -------------------- | -------------------------- | ---------------------- |\n| `DidString`          | `did:method:specific-id`   | `isDidString`          |\n| `HandleString`       | DNS-style handle           | `isHandleString`       |\n| `AtIdentifierString` | DID **or** handle          | `isAtIdentifierString` |\n| `AtUriString`        | `at://…`                   | `isAtUriString`        |\n| `UriString`          | any `scheme:…` URI         | `isUriString`          |\n| `NsidString`         | `app.bsky.feed.post`       | `isNsidString`         |\n| `TidString`          | timestamp identifier       | `isTidString`          |\n| `RecordKeyString`    | rkey (TID or literal)      | `isRecordKeyString`    |\n| `CidString`          | CID in string form         | `isCidString`          |\n| `LanguageString`     | BCP-47 tag (`en`, `pt-BR`) | `isLanguageString`     |\n| `DatetimeString`     | AT Proto datetime          | `isDatetimeString`     |\n\nOnly `AtUriString`, `AtIdentifierString` and `DatetimeString` also ship\n`as…` / `assert…` / `if…` variants. For the rest, the generic format helpers\ncover the same ground:\n\n```ts\nimport { asStringFormat, ifStringFormat, isStringFormat } from '@atproto/lex'\n\nisStringFormat(value, 'did') // narrows to DidString\nasStringFormat(value, 'nsid') // throws TypeError on invalid\nifStringFormat(value, 'handle') // HandleString | undefined\n```\n\n`isAtUriString`, `isLanguageString` and `isStringFormat` accept\n`{ strict: false }` for a lenient pass that tolerates non-conforming\nreal-world data.\n\n> [!NOTE]\n> These types are defined in `@atproto/syntax` and re-exported through\n> `@atproto/lex`. Import from `@atproto/lex` where the package already depends\n> on it — that keeps identifier types and generated schemas coming from one\n> place and avoids adding a `@atproto/syntax` dependency.\n\n### Boundaries\n\nProtobuf messages, data-plane responses and Kysely rows all arrive as bare\n`string`. Brand them once at the boundary so downstream code is typed, instead\nof asserting at each use:\n\n```ts\ndids: dids as DidString[],\npost: { uri: item.uri as AtUriString, cid: item.cid || undefined },\n```\n\nAn `as` cast is appropriate where the value provably came from a validated\nsource (your own DB, a schema-validated response). Where it did not — env\nvars, CLI args, user input — run the guard: `assert(isDidString(serverDid))`.\n\n## Blob references\n\nTwo shapes coexist on the network:\n\n```ts\ntype TypedBlobRef = { $type: 'blob'; ref: Cid; mimeType: string; size: number }\ntype LegacyBlobRef = { cid: string; mimeType: string } // no $type, no size\ntype BlobRef = TypedBlobRef | LegacyBlobRef\n```\n\nNew uploads are always `TypedBlobRef` — the PDS `uploadBlob` endpoint returns\none. Reading code has to tolerate both, which is what the format-agnostic\naccessors are for:\n\n```ts\nimport {\n  type BlobRef,\n  enumBlobRefs,\n  getBlobCid,\n  getBlobCidString,\n  getBlobMime,\n  getBlobSize,\n  isLegacyBlobRef,\n  isTypedBlobRef,\n} from '@atproto/lex'\n\ngetBlobCid(blob) // Cid — parses the string for legacy refs, so it can throw\ngetBlobCidString(blob) // string — skips the parse, prefer it when you only need the string\ngetBlobMime(blob) // string\ngetBlobSize(blob) // number | undefined — legacy refs carry no size\n\n// Deep-walk a record for its blobs (what the PDS does on record write/import)\nfor (const ref of enumBlobRefs(record, { allowLegacy: true, strict: false })) {\n  /* … */\n}\n```\n\n`enumBlobRefs` skips legacy refs unless `allowLegacy: true`.\n\n`BlobRef` is an interface, so `instanceof` does not apply — use `isBlobRef` /\n`isTypedBlobRef` / `isLegacyBlobRef`.\n\n### What `strict` means for blobs\n\nThe word means different things at two layers, and conflating them causes\nconfusing validation failures:\n\n- On the **guards** (`isBlobRef`, `isTypedBlobRef`, `isLegacyBlobRef`,\n  `enumBlobRefs`), `strict` constrains the _CID flavor_. Default `true`\n  requires a raw v1 sha256 CID; `strict: false` accepts any CID (and tolerates\n  `size: -1`). It does not reject the legacy shape — `isBlobRef` accepts legacy\n  refs either way.\n- On **schema validation** (`l.blob()`), `strict: true` (the default) rejects\n  legacy refs outright and enforces `accept` / `maxSize`; `strict: false`\n  admits them and skips those checks.\n\nA `Client` constructed with `strictResponseProcessing: false` threads\n`strict: false` through both response parsing and schema validation, which is\nhow you read a server that still emits legacy refs.\n\n## String lengths\n\nLexicon string constraints are measured in graphemes (user-perceived\ncharacters) or UTF-8 bytes, never in JS string length:\n\n```ts\nimport { graphemeLen, utf8Len } from '@atproto/lex'\n\ngraphemeLen('👨‍👩‍👧‍👦') // 1\nutf8Len('👨‍👩‍👧‍👦') // 25\n```\n\n## Comparison\n\n`lexEquals(a, b)` deep-compares two `LexValue`s, handling `Cid` and\n`Uint8Array` where `===` and `JSON.stringify` would not.\n\n## Related skills\n\n- [lex-schema](../lex-schema/SKILL.md) — validating these values against\n  Lexicon schemas.\n- [lex-setup](../lex-setup/SKILL.md) — adding `@atproto/lex-cbor` and the rest\n  of the package family to a package.\n- [lexification-client](../lexification-client/SKILL.md) — migrating off\n  `multiformats` `CID`, the `BlobRef` class from `@atproto/lexicon`, and\n  `jsonStringToLex` / `stringifyLex`.\n",".agents/skills/lex-schema/SKILL.md":"---\nname: lex-schema\ndescription: >\n  Validate, parse, construct, and type AT Protocol data against Lexicon schemas\n  with `@atproto/lex` / `@atproto/lex-schema`. Use whenever code reaches into a\n  generated `./lexicons/` tree — `$parse`, `$safeParse`, `$validate`,\n  `$matches`, `$assert`, `$build`, `$isTypeOf`, `$type`, `$nsid`, `$lxm`,\n  `$Params`, `$Input`, `$Output`, `$Message`, `.Main` — when checking whether an\n  unknown record or XRPC payload conforms, when loosening checks with\n  `strict: false`, when declaring a schema by hand with the `l` builder, when\n  handing a Lexicon schema to a Standard Schema consumer, or when replacing\n  Zod/Ajv-style validation with Lexicon validation. Not for codegen setup (see\n  lex-setup) nor for the value types themselves — CIDs, bytes, blobs, JSON/CBOR\n  encoding, branded strings (see lex-data).\ndisable-model-invocation: false\n---\n\n# Generated schemas and the `l` builder\n\n`lex build` compiles each Lexicon document into a TypeScript module carrying\nboth a runtime schema and its types, addressed by NSID dot-path through the\nnamespace tree:\n\n```ts\nimport { app, com } from './lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def schema\ncom.atproto.repo.createRecord // query/procedure schema\n```\n\nThe tree lives in `./src/lexicons/` (plural), is gitignored, and is regenerated\nby `lex build` — never edit it. See [lex-setup](../lex-setup/SKILL.md).\n\n## Accessors on a generated namespace\n\n| Accessor                | Schema kind             | What it is                                          |\n| ----------------------- | ----------------------- | --------------------------------------------------- |\n| `$nsid`                 | all                     | NSID of the document (`'app.bsky.feed.post'`)       |\n| `$type`                 | record / typed object   | `$type` string; equals `$nsid` for `main`           |\n| `$lxm`                  | query / procedure / sub | XRPC method ID — auth checks, proxy routing         |\n| `$params` / `$Params`   | query / procedure / sub | Params schema / its parsed type                     |\n| `$input` / `$Input`     | procedure               | Request payload schema / `{ encoding, body }` type  |\n| `$InputBody`            | procedure               | Request body type alone                             |\n| `$output` / `$Output`   | query / procedure       | Response payload schema / `{ encoding, body }` type |\n| `$OutputBody`           | query / procedure       | Response body type alone                            |\n| `$message` / `$Message` | subscription            | Message schema / message type                       |\n| `.Main`                 | record / object         | The main definition's TS type                       |\n\n`$Input` / `$Output` / `$InputBody` / `$OutputBody` take an optional binary-body\ntype parameter (`$Output<Buffer>`); the default is the opaque `l.BinaryData`\nplaceholder.\n\nSub-definitions sit alongside `.Main` — type in PascalCase, schema in camelCase:\n\n```ts\ntype ReplyRef = app.bsky.feed.post.ReplyRef\napp.bsky.feed.defs.postView // schema\ntype PostView = app.bsky.feed.defs.PostView // type\n```\n\n**Never write NSID string literals.** Use `$type` / `$lxm` / `$token`: the\nschema stays the single source of truth and each constant is one shared string\ninstance (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md#lexicons)). Tokens are\nalso available as PascalCase export (e.g., `app.bsky.feed.defs.RequestLess`),\nprefer this over the `$token` notation.\n\n## Validation methods\n\nEvery schema exposes these as plain methods; generated `main` definitions also\nre-export them as pre-bound `$`-prefixed consts, so\n`const { $parse } = app.bsky.feed.post` and\n`export const validateStrongRef = com.atproto.repo.strongRef.$safeValidate`\nboth work.\n\n| Method                       | On invalid  | Transforms input?      | Returns                        |\n| ---------------------------- | ----------- | ---------------------- | ------------------------------ |\n| `$parse(data, opts?)`        | throws      | yes                    | parsed value (output type)     |\n| `$safeParse(data, opts?)`    | result      | yes                    | `{ success, value }` \\| error  |\n| `$validate(data, opts?)`     | throws      | no                     | the _same_ value, narrowed     |\n| `$safeValidate(data, opts?)` | result      | no                     | `{ success, value }` \\| error  |\n| `$assert(data, opts?)`       | throws      | no                     | `void`, narrows via `asserts`  |\n| `$check(data, opts?)`        | throws      | no                     | `void`, no narrowing           |\n| `$cast(data, opts?)`         | throws      | no                     | same as `$validate`            |\n| `$matches(data, opts?)`      | `false`     | no                     | type-guard boolean             |\n| `$ifMatches(data, opts?)`    | `undefined` | no                     | the value, or `undefined`      |\n| `$isTypeOf(value)`           | `false`     | no — **no validation** | type-guard on `$type` only     |\n| `$build(data)`               | n/a         | adds `$type` only      | typed literal, **unvalidated** |\n\n### Parse vs. validate\n\nThe distinction is whether the schema may hand back a _different_ value.\n`$parse` runs in parse mode: `withDefault` defaults are filled in, `l.bytes()`\nnormalizes any typed-array view to `Uint8Array`, a `TokenSchema` instance is\naccepted in place of its string, and objects/arrays/params are copied on write\nwhen a child transforms. `$validate` runs the same checks but treats any\ntransformation as a failure, so a success guarantees the input already\nconformed as-is.\n\nThat guarantee is why validate mode is the right choice on the read path: the\nAppView hydrates stored records with `$matches` / `$safeValidate` rather than\n`$parse`, because a value with defaults applied would no longer hash to the CID\nit was stored under.\n\nParse mode does **not** decode the Lex JSON encoding — `{ \"$link\": … }` stays a\nplain object and fails CID validation. Convert first with `jsonToLex` /\n`lexParse` from `@atproto/lex-json` (see [lex-data](../lex-data/SKILL.md)).\n\n### Result shape\n\nOn failure the result _is_ a `LexValidationError` (the class implements the\nfailure shape), so `result.reason` is the error itself and `result.issues` is\nthe issue list. There is no `.error` payload object — `error` is the XRPC error\ncode string (`'InvalidRequest'`).\n\n```ts\nconst result = app.bsky.feed.post.$safeParse(unknownData)\nif (result.success) {\n  result.value // app.bsky.feed.post.Main\n} else {\n  result.reason // LexValidationError (=== result)\n  result.issues // readonly Issue[], each carrying a path\n}\n```\n\n### `$assert` vs `$check`\n\n`$assert` narrows via an `asserts` signature, which TypeScript only permits when\nthe call target has an explicit type annotation. In generic code that yields:\n\n> Assertions require every name in the call target to be declared with an\n> explicit type annotation. `ts(2775)`\n\n`$check` is the same runtime check without the narrowing — reach for it only to\nresolve that error.\n\n```ts\ndeclare const schema: RecordSchema | ObjectSchema\nschema.$check(data) // void, no ts(2775)\n```\n\n### `$isTypeOf` — discriminate, don't validate\n\nOnly compares `$type`, so it is cheap enough for hot `.find` / `.filter` loops\nover already-validated unions:\n\n```ts\nconst pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nMind the asymmetry: on a **record** schema it demands an exact `$type` match,\nbut on a **typed object** (def) schema a _missing_ `$type` also passes, since\n`$type` is optional on nested defs. On unvalidated data it proves nothing about\nthe rest of the shape.\n\n### `$build` — construct with `$type`, no validation\n\nRecords require `$type`, so hand-building one means repeating the NSID.\n`$build` sets it and types the result; it applies no defaults and runs no\nchecks, so follow with `$parse` when the input is untrusted.\n\n```ts\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello, world!',\n  createdAt: l.currentDatetimeString(),\n})\n```\n\nDefs whose `$type` is optional need no runtime call — annotate instead. Use\n`Un$Typed<T>` when a helper returns a def's fields and the caller adds `$type`:\n\n```ts\nconst view: app.bsky.feed.defs.PostView = {/* ... */}\nfunction toPrefs(): Un$Typed<app.bsky.notification.defs.Preferences> {\n  /* ... */\n}\n```\n\n### Record keys\n\nA record schema carries its key schema, for validating an rkey independently of\nthe record body:\n\n```ts\nconst result = app.bsky.feed.post.keySchema.safeValidate(rkey)\n```\n\n## Strict mode\n\nEvery validating method takes `{ strict?: boolean, path?: PropertyKey[] }`,\nstrict defaulting to `true`. Strict means \"conforms to the AT Protocol spec\";\nlenient exists because data already on the network predates parts of it.\n\n| Concern         | `strict: true` (default)                  | `strict: false`                             |\n| --------------- | ----------------------------------------- | ------------------------------------------- |\n| `datetime`      | timezone required                         | any ISO-ish datetime                        |\n| `at-uri`        | record-key component validated            | record key not validated                    |\n| `language`      | RFC 5646 §4.1 semantics enforced          | well-formed BCP 47 syntax only              |\n| blob constraint | `accept` MIME list and `maxSize` enforced | not enforced                                |\n| blob ref CID    | strict CID check                          | any valid CID                               |\n| legacy blob ref | `{ cid, mimeType }` rejected              | accepted, including the sentinel `size: -1` |\n\n```ts\nschema.$safeParse(data, { strict: false })\n```\n\n`Client`'s `strictResponseProcessing: false` threads this through every\nresponse — prefer it over sprinkling per-call options. See\n[lex-client](../lex-client/SKILL.md).\n\n## Tokens\n\nA Lexicon `token` def exports its schema in camelCase and its string value in\nPascalCase. The PascalCase export shares its name with the string literal type:\n\n```ts\napp.bsky.graph.defs.curatelist // TokenSchema\napp.bsky.graph.defs.Curatelist // 'app.bsky.graph.defs#curatelist'\ntype Curatelist = app.bsky.graph.defs.Curatelist\n```\n\n## Declaring schemas with `l`\n\nGenerated schemas cover anything backed by a Lexicon document. Use the `l`\nbuilder for one-off internal shapes, or when authoring a schema before its\nLexicon JSON exists — otherwise prefer `./lexicons/`, which stays in sync with\nthe contract.\n\n```ts\nimport { l } from '@atproto/lex'\n\nconst thing = l.typedObject(\n  'com.example.thing', // NSID\n  'main', // definition name\n  l.object({\n    name: l.string({ maxGraphemes: 64 }),\n    count: l.optional(l.withDefault(l.integer({ minimum: 0 }), 0)),\n  }),\n)\n\ntype Thing = l.Infer<typeof thing> // input type; l.InferOutput for post-parse\n```\n\nNote the shape: document builders take `(nsid, name, validator)` — a validator,\nnot a bare property map. `l.object()` is what turns a property map into one.\n\n- Primitives — `l.string()`, `l.integer()`, `l.boolean()`, `l.bytes()`,\n  `l.cid()`, `l.blob()`, `l.null()`, `l.never()`, `l.unknown()`, `l.lexMap()`,\n  `l.lexValue()`\n- Composites — `l.object()`, `l.array()`, `l.dict()`, `l.union()`,\n  `l.discriminatedUnion()`, `l.intersection()`, `l.ref()`, `l.literal()`,\n  `l.enum()`, `l.regexp()`, `l.refine()`, `l.custom()`\n- Modifiers — `l.optional()`, `l.nullable()`, `l.withDefault()`\n- Lexicon documents — `l.record()`, `l.typedObject()`, `l.typedRef()`,\n  `l.typedUnion()`, `l.token()`, `l.query()`, `l.procedure()`,\n  `l.subscription()`, `l.params()`, `l.payload()`, `l.jsonPayload()`\n\n`l` also carries the schema classes (`RecordSchema`, `ObjectSchema`, …), the\ninference helpers (`l.Infer`, `l.InferInput`, `l.InferOutput`), the branded\nstring types and format guards, and the datetime helpers\n(`l.currentDatetimeString`, `l.toDatetimeString`, `l.asDatetimeString`,\n`l.isDatetimeString`, `l.ifDatetimeString`).\n\n`@atproto/lex` re-exports all of it flat as well, but importing `l` avoids\ncolliding with globals and generic names (`string`, `object`, `record`).\n\n`ParamsSchema` additionally exposes `fromURLSearchParams()` (coercing each\nstring to the declared scalar type) and `toURLSearchParams()` — use these\ninstead of hand-rolled query-string handling.\n\n## Standard Schema\n\nEvery schema implements [Standard Schema](https://standardschema.dev/) v1 via\n`~standard`, so form libraries and validators speaking the spec accept a\nLexicon schema directly:\n\n```ts\nconst result = app.bsky.feed.post['~standard'].validate(data)\nif (!result.issues) result.value\n```\n\nThe adapter always runs in **parse mode**, so defaults and coercion apply even\nwhere a caller might expect a pure check.\n\n## Related skills\n\n[lex-setup](../lex-setup/SKILL.md) generates this tree;\n[lex-data](../lex-data/SKILL.md) covers the values these schemas validate\n(CIDs, bytes, blobs, JSON/CBOR, branded strings);\n[lex-client](../lex-client/SKILL.md) and\n[xrpc-server](../xrpc-server/SKILL.md) cover passing schemas to calls and\nroutes.\n",".agents/skills/lex-setup/SKILL.md":"---\nname: lex-setup\ndescription: >\n  Wire up, maintain, or troubleshoot `@atproto/lex` code generation and the\n  per-package `lexicons.json` manifest. Use when a package needs generated\n  Lexicon schemas; when adding or editing a `lexicons.json`, its `resolvers`\n  (`directory` / `http` / `repo`) or a `lexicons-overrides` directory; when\n  migrating a package off compiling the repo-root `lexicons` directory; when\n  adding a `codegen:lex` / `lex:install` / `prebuild` script; when `./src/lexicons/`\n  or `./lexicons/` is missing, stale, or gitignored wrong; after editing anything\n  under `lexicons/`; when adding an NSID; when `pnpm codegen`, `lex build`, or\n  `lex install` fails; when `lex` resolves to the wrong binary; when a\n  post-lexicon-edit build reports \"type X is not assignable to Y\"; or when ripping\n  out legacy `@atproto/lex-cli` codegen. Reach for it before hand-editing anything\n  under a `lexicons/`, `lexicons-overrides/`, or `src/lexicons/` directory, or any\n  `lexicons.json`.\ndisable-model-invocation: false\n---\n\n# `@atproto/lex` codegen setup\n\nTwo commands do everything:\n\n- **`lex build`** — compiles Lexicon JSON into a TypeScript schema tree. Every\n  package that uses `@atproto/lex` runs this.\n- **`lex install`** — resolves the Lexicons a package's `lexicons.json` lists\n  into `./lexicons/`, recording each one's source + CID, from the manifest's\n  `resolvers` and the network.\n\nBoth ship in the `lex` bin of `@atproto/lex`. Add `@atproto/lex` as a\ndependency — it re-exports `@atproto/lex-client`, `-schema`, `-data`, and\n`-json`, so one dependency covers the common case. `@atproto/lex-cbor` is\n_not_ re-exported; depend on it directly if the package encodes/decodes CBOR.\n\n## Wiring a package in this monorepo\n\n[lexicons/](../../../lexicons/) at the repo root is the canonical source for the\n`com.atproto` and `tools.ozone` namespaces **only**. Every package manages its\nLexicons through its own committed `lexicons.json` manifest and resolves them\nwith `lex install` — no package compiles the root directory directly. The\nmanifest's `resolvers` decide where each NSID comes from; see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson).\n\nCopy the shape consuming packages use\n([packages/pds](../../../packages/pds/package.json),\n[packages/sync](../../../packages/sync/package.json),\n[packages/bsky](../../../packages/bsky/package.json)):\n\n```json\n{\n  \"scripts\": {\n    // Generate TypeScript schema from Lexicon JSON\n    \"lex:build\": \"lex build --clear --indexFile\",\n    // Install Lexicon JSON into ./lexicons/ and update the manifest. Use `pnpm lex:install foo.bar.baz` to install new lexicons\n    \"lex:install\": \"lex install\",\n    // Keep the TypeScript schema up to date after installing new lexicons\n    \"postlex:install\": \"pnpm run lex:build\",\n    // Allows to keep the manifest.json up-to-date when a canonical lexicon is updated. Also allows to fetch new lexicons from the network.\n    \"lex:update\": \"lex update\",\n    // Run the build step after updating the lexicons\n    \"postlex:update\": \"pnpm run lex:build\",\n    // When performing a regular build (eg. from CI), ensure that the lexicons are installed and match the manifest.\n    \"codegen:lex\": \"pnpm run lex:install --ci\",\n    \"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n    \"build\": \"tsc --build tsconfig.build.json\"\n  }\n}\n```\n\nThen gitignore the build artifacts. The generated TypeScript (`src/lexicons`) is\nnever committed, and the canonical schemas symlinked into `./lexicons` from the\nrepo root (`com.atproto` / `tools.ozone`) are gitignored too — they can't drift\nand are recreated on every install. Everything else resolved into `./lexicons` is\n**committed**: the `at://` network copies and the `app.bsky.*` / `chat.bsky.*`\ndocuments fetched from the bsky GitHub repo, so builds and CI work offline (the\nnetwork can't serve old record versions). The shared repo-root\n[lexicons-overrides/](../../../lexicons-overrides/) is committed too. See\n[packages/bsky/.gitignore](../../../packages/bsky/.gitignore) for the shape:\n\n```\n# @atproto/lex\n/src/lexicons\n# Canonical schemas are symlinked from the repo-wide source; everything else\n# under ./lexicons is a committed copy kept for offline builds and CI.\n/lexicons/com/atproto\n/lexicons/tools/ozone\n```\n\nA package whose Lexicons are _all_ symlinked canonical (e.g.\n[packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json))\nhas nothing to commit under `./lexicons` and can gitignore the whole `/lexicons`\ntree instead.\n\nThe indirection through `codegen:lex` is deliberate:\n\n- `prebuild` runs every `codegen:*` script the package declares, so a package\n  that also generates protobuf or templates needs no change to this line.\n  `enable-pre-post-scripts = true` in `.npmrc` is what makes a build fire\n  `prebuild` at all.\n- Root `pnpm codegen` runs `build:tooling` and then every `codegen:*` script\n  across the workspace in parallel. A generator hidden inside `prebuild` would\n  be skipped by it.\n\n### Which `lex` binary you get\n\n`@atproto/lex-cli` (legacy) and `@atproto/lex` both claim the `lex` bin name,\nand resolution is per-package. In `packages/api` — which still depends on\n`lex-cli` for `gen-api` — `pnpm exec lex` gives you the **legacy** CLI, not\n`lex build`. `api` therefore installs its manifest with the `lex-installer`\nbin (`@atproto/lex-installer`) and feeds the installed `./lexicons` to\n`gen-api`. Everywhere else `lex` is `@atproto/lex`.\n\n`gen-api` is slow, so `api`'s `lex:gen` is mtime-guarded: it only re-runs when\n`./src/client/index.ts` is missing or some lexicon content is newer than it.\nUse `find -L ./lexicons -type f` (not the symlinks themselves) — `lex install`\nre-creates the canonical symlinks and rewrites `lexicons.json` on every run, so\ntheir mtimes are meaningless; `-L` follows each symlink to its (stable) target,\nand the regular files are the committed/fetched copies.\n\nIf a `lex build` invocation errors about an unknown command, that collision is\nwhy. `@atproto/lex` also registers `ts-lex`, which is unambiguous. Outside a\nworkspace, `npx @atproto/lex` avoids pulling the unrelated unscoped `lex`\npackage that bare `npx lex` would fetch.\n\n### Manifest resolvers (`lexicons.json`)\n\nThe manifest's `lexicons` array lists the NSID **entry points** the package\nneeds; `lex install` pulls in schemas they reference transitively, so you only\nlist the roots. `resolvers` is an **ordered** list deciding where each NSID is\nfetched from — earlier entries win, with the AT Protocol network as the implicit\nlast fallback. The kinds:\n\n- `{ \"type\": \"directory\", \"path\": \"../../lexicons\", \"include\": [\"com.atproto.*\", \"tools.ozone.*\"] }`\n  — resolve from a local directory. The canonical set lives at the repo root;\n  matches are **symlinked** into `./lexicons` and locked with a `file://` URI, so\n  they can't drift. Adjust `path` for the package's depth.\n- `{ \"type\": \"http\", \"base\": \"https://raw.githubusercontent.com/bluesky-social/bsky/refs/heads/main/lexicons/\", \"include\": [\"app.bsky.*\", \"chat.bsky.*\"] }`\n  — fetch `<base>/<…nsid.segments>.json` (e.g. a raw Git host). `http` falls\n  through on 404 but throws on other errors.\n- `{ \"type\": \"repo\", \"repo\": \"<handle|did>\" }` — resolve each NSID from that\n  repo's `com.atproto.lexicon.schema` records; never falls through.\n- A `directory` resolver pointing at the shared, committed repo-root\n  **`../../lexicons-overrides`** directory, listed **after** the canonical\n  resolver but **before** the `http`/network ones, so it overrides the lexicons\n  they would otherwise return (it cannot override canonical, which resolves\n  first). Put any document that must differ here: unpublished NSIDs (`internal.*`,\n  `place.stream.badge.defs`, a not-yet-published `app.bsky.unspecced.*`),\n  documents whose local version is ahead of the published one, and minimal\n  compatibility relaxations of published third-party lexicons that network\n  records violate (see\n  [Compatibility overrides](../../../lexicons-overrides/README.md#compatibility-overrides);\n  the PDS must `exclude` them so writes keep validating against the published\n  schema). All packages share\n  the one [lexicons-overrides/](../../../lexicons-overrides/) at the repo root.\n\n[packages/bsky/lexicons.json](../../../packages/bsky/lexicons.json) is the full\nreference (overrides + canonical directory + GitHub `http`).\n\n`include`/`exclude` globs accept `*` as a wildcard; `exclude` is applied after\n`include`.\n\n### Packages under `packages/lex/`\n\n`@atproto/lex` depends on the SDK sub-packages, so they cannot depend back on it\n(it would be circular). They generate with `@atproto/lex-builder`'s `lex-builder\nbuild --lib @atproto/lex-schema` (that is where the generated files import `l`\nfrom) instead of `lex build`, and install their manifest with `@atproto/lex-installer`'s\n`lex-installer install` instead of `lex install`:\n\n```jsonc\n\"lex:build\": \"lex-builder build --out ./src/lexicons --clear --lib @atproto/lex-schema --index-file\",\n\"lex:install\": \"lex-installer install\",\n\"postlex:install\": \"pnpm run lex:build\",\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n```\n\nSee [packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json).\nAdd `--default-export` to `lex:build` for a browser-facing client\n([packages/lex/lex-client](../../../packages/lex/lex-client/package.json)).\n\n## `lex build` flags\n\nDefaults: `--lexicons ./lexicons`, `--out ./src/lexicons`, `--no-pretty`,\n`--default-export`. yargs accepts either kebab-case or camelCase\n(`--index-file` == `--indexFile`).\n\n- `--clear` — wipe the output dir first, so deleted lexicons don't linger\n- `--override` — overwrite existing files (no-op alongside `--clear`)\n- `--indexFile` — emit `index.ts` re-exporting root namespaces (`app`, `com`, …)\n- `--include` / `--exclude <patterns…>` — filter by NSID\n- `--lib <package>` — where generated files import `l` from\n- `--importExt <ext>` — import specifier extension (`.js`; `\"\"` for extensionless)\n- `--fileExt <ext>` — emitted file extension (`.ts`)\n- `--no-pretty` — skip prettier\n- `--no-defaultExport` — drop `default` re-exports (see [Tree-shaking](#tree-shaking))\n- `--defsExport` — also expose defs under `$defs`, for when a child namespace\n  shadows a sibling definition (`com.example.foo` vs `com.example.foo.bar`)\n- `--ignore-errors` / `--ignore-invalid-lexicons` — skip bad inputs instead of failing\n\n## Using the generated tree\n\n`--indexFile` gives one entry point for all root namespaces; schemas are\naddressed by NSID dot-path:\n\n```ts\nimport { app, com, chat } from '../lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def\ncom.atproto.repo.getRecord // query/procedure schema\n```\n\nEach NSID compiles to a `<name>.defs.ts` holding the schema plus a thin\n`<name>.ts` re-exporting it (and `main` as `default`). Nothing under\n`src/lexicons/` is hand-editable — it is deleted and rewritten on every\n`--clear` build.\n\n### Tree-shaking\n\nThe generated tree is tree-shakeable, but the reference style decides how much\nsurvives. A bundler can't tell that `client.call()` only consumes `.main`, so\nnaming the namespace retains every sibling def inside it:\n\n```ts\n// Smallest — default import; preferred for browser bundles\nimport getRecord from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(getRecord, {})\n\n// Same size, but leaks the `main` identifier into your source\nimport { main as getRecord } from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(com.atproto.repo.getRecord.main, {})\n\n// Largest — namespace notation drags in sibling defs\nawait client.call(com.atproto.repo.getRecord, {})\n```\n\nServices, scripts, and tests here use namespace notation, which reads closest\nto the NSID and costs nothing server-side. Libraries and browser apps should\nprefer the default import.\n\n## After editing lexicon JSON\n\nGenerated TypeScript is not rebuilt automatically. Regenerate before building\nor testing anything downstream — a stale tree is by far the most common cause\nof \"type X is not assignable to Y\" right after a lexicon edit:\n\n```bash\npnpm codegen        # repo root: every codegen:* script in the workspace\npnpm run prebuild   # inside one package\n```\n\n`pnpm codegen` builds `@atproto/lex-builder` first via `build:tooling`. If\ncodegen output still looks stale, that build is the thing to re-run.\n\nAdding a new external NSID means listing it in the package's `lexicons.json`\n`lexicons` array (or `pnpm lex:install <nsid>`), then re-running codegen. A new\ncanonical (`com.atproto.*` / `tools.ozone.*`) schema is added at the repo root\nand picked up by each dependent package's next `lex:install`; run\n`pnpm lex:update-local` from the root to propagate a changed canonical CID into\nevery manifest.\n\n## Network-installed schemas (outside this repo)\n\nFor a package whose Lexicons are _not_ maintained alongside it, `lex install`\nfetches them from the network into `./lexicons/` and records each resolution\n(AT URI + CID) in `./lexicons.json`.\n\n```bash\nlex install app.bsky.feed.post   # add NSIDs; dependencies resolve automatically\nlex install                      # install everything the manifest lists\nlex install --update             # re-resolve all to latest\nlex install --ci                 # fail if installed files drift from manifest CIDs\nlex install --no-save <nsid>     # install without touching the manifest\n```\n\nFlags: `--manifest <path>` (default `./lexicons.json`), `--lexicons <dir>`\n(default `./lexicons`), `--update`, `--ci`, `--no-save`.\n\nCommit both the manifest and the fetched JSON — they are the input contract,\nand the recorded CIDs are what makes `--ci` meaningful. The upstream-recommended\nwiring adds `\"postinstall\": \"lex install --ci\"` to catch drift on every install\nand `\"update-lexicons\": \"lex install --update\"` as the deliberate refresh.\n\nDon't commit a second **copy** of schemas that already live in\n[lexicons/](../../../lexicons/) — it would silently diverge. In-repo, vendor by\n**symlink** instead (next section), which can't drift.\n\n## Vendoring the canonical lexicons (in-repo)\n\nConsuming [lexicons/](../../../lexicons/) through `lex install` (rather than\ncompiling it directly) is the standard setup — it makes a package's lexicon set\nan explicit, CID-locked contract. A `directory` resolver resolves each canonical\nNSID from the root; those resolutions are **symlinked** into `./lexicons/` (not\ncopied) and locked with a `file://` URI relative to the manifest, so they can't\ndiverge. [packages/pds](../../../packages/pds/package.json) is the reference:\n\n```jsonc\n// lexicons.json (committed) — roots + resolvers + CID locks\n{\n  \"version\": 2,\n  \"lexicons\": [\"com.atproto.repo.getRecord\", \"…every vendored NSID…\"],\n  \"resolvers\": [\n    {\n      \"type\": \"directory\",\n      \"path\": \"../../lexicons\",\n      \"include\": [\"com.atproto.*\", \"tools.ozone.*\"],\n    },\n  ],\n  \"resolutions\": {\n    \"com.atproto.repo.getRecord\": {\n      \"uri\": \"file://../../lexicons/com/atproto/repo/getRecord.json\",\n      \"cid\": \"…\",\n    },\n  },\n}\n```\n\n```jsonc\n// package.json — install (creating symlinks + verifying the lock), then build\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n\"lex:install\": \"lex install\",     // regenerate the manifest after adding/removing a lexicon\n\"postlex:install\": \"pnpm run lex:build\",\n\"lex:build\": \"lex build --clear --indexFile\",\n```\n\nGitignore the symlinked canonical dirs under `/lexicons` (`com/atproto`,\n`tools/ozone` — build artifacts) but **commit `lexicons.json`** (the contract),\nalong with any network- or GitHub-resolved copies. Generate/refresh the manifest with\n`pnpm run lex:install`; `--ci` then fails the build if `/lexicons` drifts from\nthe locked CIDs. Tradeoff: the `lexicons` roots list is explicit, so adding a\nnew canonical schema requires re-running `lex:install` to include it — unlike a\nplain `lex build --lexicons ../../lexicons`, which picks up every file\nimplicitly. For the full resolver vocabulary (`directory` / `http` / `repo`,\nordering, and the `lexicons-overrides` directory) see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson) above.\n\n## Removing legacy `@atproto/lex-cli` codegen\n\nThe legacy output directory depends on which generator ran — `lex gen-server`\nemits `./src/lexicon` (singular), `lex gen-api` emits `./src/client`. Both are\nfully generated and safe to delete. Leave `lexicons/` alone; it is input, not\noutput.\n\nThen drop `@atproto/lex-cli` from `devDependencies`, add a `lexicons.json`\n(resolving the canonical set from the root plus whatever external namespaces the\npackage used), replace the old `codegen:lex` with the `lex:install` / `lex:build`\nform above, and repoint `.gitignore` at `src/lexicons` plus the symlinked\ncanonical dirs under `/lexicons`. (`packages/api` is the exception — it keeps `gen-api` but now\nfeeds it a locally-installed `./lexicons`; see\n[Which `lex` binary you get](#which-lex-binary-you-get).)\n\n## Related skills\n\nOnce the package generates code: [lex-schema](../lex-schema/SKILL.md)\n(`$`-accessors, validation), [lex-data](../lex-data/SKILL.md) (values, blobs,\nCBOR, branded strings), [lex-client](../lex-client/SKILL.md) (calling out),\n[xrpc-server](../xrpc-server/SKILL.md) (defining routes), and\n[lexification-client](../lexification-client/SKILL.md) /\n[lexification-server](../lexification-server/SKILL.md) (migrating off the\nlegacy stack).\n",".agents/skills/lexification-client/SKILL.md":"---\nname: lexification-client\ndescription: >\n  Migrate AT Protocol client and consumer code off the legacy `@atproto/api`,\n  `@atproto/lexicon`, and `@atproto/xrpc` stack onto `@atproto/lex`. Use\n  whenever code that *calls out* to an XRPC service is being modernized —\n  replacing an `AtpAgent` with `Client`, `result.data` / `result.headers[…]`\n  response handling, `XRPCError` try/catch, `jsonStringToLex` / `stringifyLex`,\n  `BlobRef` `instanceof` checks, `CID` from `multiformats`,\n  `new Date().toISOString()` on AT Proto datetimes, bare `string` DIDs and\n  handles, or legacy `isX()` type guards — including when the request is\n  phrased as \"clean this up\" or \"modernize this file\" rather than \"migrate\".\n  For code that *defines* routes, use the lexification-server skill.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating client / consumer code\n\nScope: **calls out**. Anything that used `AtpAgent` or the `@atproto/lexicon` /\n`@atproto/xrpc` runtime to talk to an AT Proto service, plus the data types\nthat flow across that boundary.\n\nRoute _definitions_ (`server.add`, handler signatures, codegen removal) are the\n[lexification-server skill](../lexification-server/SKILL.md). A service package\nalmost always needs both: it defines routes **and** calls other services. If\nyou're mid-migration on a service and reach an `agent.api.…` call, you're in\nthe right file.\n\n## What changes\n\n| Old                                               | New                                                                    |\n| ------------------------------------------------- | ---------------------------------------------------------------------- |\n| `AtpAgent` from `@atproto/api`                    | `Client` from `@atproto/lex`                                           |\n| `agent.api.<ns>.<method>(params, { headers })`    | `client.call(<schema>, params, opts)` or `client.xrpc(<schema>, opts)` |\n| `result.data`                                     | `result.body`                                                          |\n| `result.headers['content-language']`              | `result.headers.get('content-language')` (standard `Headers`)          |\n| `XRPCError` from `@atproto/xrpc`                  | `XrpcError` from `@atproto/lex`                                        |\n| try/catch + `instanceof XRPCError`                | `xrpcSafe()` / `client.xrpcSafe()` discriminated result                |\n| `HeadersMap` from `@atproto/xrpc`                 | `Headers as HeadersMap` from `@atproto/xrpc-server`                    |\n| `jsonStringToLex` / `stringifyLex`                | `lexParse` / `lexStringify`                                            |\n| `BlobRef` class from `@atproto/lexicon`           | `BlobRef` interface from `@atproto/lex-data` (or `@atproto/lex`)       |\n| `CID` from `multiformats/cid`                     | `Cid` / `parseCid` from `@atproto/lex`                                 |\n| `new Date().toISOString()` for AT Proto datetimes | `currentDatetimeString()` / `toDatetimeString()`                       |\n| `isX(v)` from `src/lexicon/types/…`               | `<ns>.<def>.$matches(v)` / `.$isTypeOf(v)`                             |\n\n## XRPC calls\n\nThree shapes, in rough order of preference — pick the least machinery that\ncovers what the call site actually uses.\n\n### `call()` — you only want the body\n\nErrors propagate as exceptions. Params (query) or body (procedure) go in the\nsecond argument; everything else in the third.\n\n```ts\nreturn ctx.suggestionsClient.call(\n  app.bsky.unspecced.getSuggestedUsersSkeleton,\n  { limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined },\n  { headers: params.headers },\n)\n```\n\n### `xrpc()` — you need status or headers too\n\n```diff\n- const res = await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton(\n-   { viewer: params.hydrateCtx.viewer, relativeToDid },\n-   { headers: params.headers },\n- )\n- return {\n-   suggestedDids: res.data.actors.map((a) => a.did),\n-   resHeaders: res.headers,\n- }\n+ const res = await ctx.suggestionsClient.xrpc(\n+   app.bsky.unspecced.getSuggestionsSkeleton,\n+   {\n+     params: { viewer: params.hydrateCtx.viewer, relativeToDid },\n+     headers: params.headers,\n+   },\n+ )\n+ const contentLang = res.headers.get('content-language')\n+ return {\n+   suggestedDids: res.body.actors.map((a) => a.did),\n+   resHeaders: contentLang ? { 'content-language': contentLang } : undefined,\n+ }\n```\n\nEverything is one options object: query params under `params`, procedure\nbodies under `body` (not `input`), plus `headers`, `signal`, `service`,\n`labelers`, `maxRetries`, and the validation flags.\n\nResponse headers are a standard `Headers` instance. If the value flows into\nsomething typed `HeadersMap` (`Record<string, string>`), pull the specific\nheaders out with `.get()` as above; if the consumer takes `Headers`, pass it\nthrough unchanged.\n\n### `xrpcSafe()` — errors are part of the contract\n\nReturns a discriminated result instead of throwing, and the failure is typed\nagainst the method schema. Use it where the call site already had a `catch`\nthat inspected the error rather than just rethrowing.\n\nNarrow on the concrete failure class when the branches map to different\ndownstream responses (see `packages/bsky/src/api/app/bsky/feed/getFeed.ts`):\n\n```ts\nimport {\n  XrpcInvalidResponseError,\n  XrpcResponseError,\n  xrpcSafe,\n} from '@atproto/lex'\n\nconst result = await xrpcSafe(fgEndpoint, app.bsky.feed.getFeedSkeleton, {\n  strictResponseProcessing: false,\n  signal: AbortSignal.timeout(10_000),\n  headers,\n  params: { feed, limit, cursor },\n})\n\nif (!result.success) {\n  const cause = result.reason\n\n  // Structurally valid XRPC error (4xx/5xx) — pass it through\n  if (cause instanceof XrpcResponseError) {\n    const { status, body } = cause.toDownstreamError()\n    throw new XRPCError(status, body.message, body.error, { cause })\n  }\n\n  // Response didn't match the schema\n  if (cause instanceof XrpcInvalidResponseError) {\n    throw new UpstreamFailureError(\n      'feed provided an invalid response',\n      'InvalidFeedResponse',\n      { cause },\n    )\n  }\n\n  // Typically a network error\n  throw new UpstreamFailureError('feed unavailable', undefined, { cause })\n}\n\nconst { feed: feedSkele, cursor } = result.body // typed\n```\n\n`toDownstreamError()` is what replaces hand-mapping upstream status codes: it\nremaps 500 → 502 and strips hop-by-hop headers. It does **not** redact — when\nthe upstream payload is a structurally valid XRPC error, its `error` / `message`\nare passed through verbatim for transparency. Sanitize them yourself before\nre-throwing if the upstream may leak internal detail.\n\nWhen you only care about one schema-declared error code, skip the class checks:\n\n```ts\nconst res = await xrpcSafe(pds, com.atproto.sync.getLatestCommit, {\n  params: { did },\n})\nif (res.success) return true\nif (res.error === 'RepoNotFound') return false\nthrow res.reason\n```\n\n`result.matchesSchemaErrors()` narrows `result.error` to the codes the method\ndeclares, for when you want the compiler to check the code strings.\n\nThe old `ResponseType` enum comparisons have no replacement and don't need one:\n`XrpcInvalidResponseError` covers `ResponseType.InvalidResponse`, and\n`XrpcInternalError` / `XrpcFetchError` cover `ResponseType.Unknown`.\n\nRetries are built in (`maxRetries`, default `0`), so delete any hand-rolled\nretry loop wrapped around the call. See [lex-client](../lex-client/SKILL.md)\nfor the full error hierarchy and client API.\n\n## Agent → Client\n\n```diff\n- import { AtpAgent } from '@atproto/api'\n+ import { Client } from '@atproto/lex'\n\n- searchAgent: AtpAgent | undefined\n+ searchClient: Client | undefined\n```\n\n`Client` takes **two** arguments: the agent (a service URL, an `AgentConfig`,\nor a session), then per-client options. Static headers belong in the agent\nconfig rather than an imperative `setHeader` after construction — one frozen\nconfig then describes the whole client (see `packages/bsky/src/index.ts`):\n\n```diff\n- const myServiceAgent = config.serviceUrl\n-   ? new AtpAgent({ service: config.serviceUrl })\n-   : undefined\n- if (myServiceAgent && config.serviceApiKey) {\n-   myServiceAgent.api.setHeader('authorization', `Bearer ${config.serviceApiKey}`)\n- }\n+ const myServiceClient = config.serviceUrl\n+   ? new Client(\n+       {\n+         service: config.serviceUrl,\n+         headers: config.serviceApiKey\n+           ? { authorization: `Bearer ${config.serviceApiKey}` }\n+           : undefined,\n+       },\n+       {\n+         // Trust internal services to send us well-formed responses\n+         strictResponseProcessing: false,\n+         validateResponse: config.debugMode,\n+       },\n+     )\n+   : undefined\n```\n\nThat second argument is worth setting for internal service-to-service clients:\n`Client` validates responses by default, which is stricter than `AtpAgent` was,\nso a migrated call can start failing on data the old code happily accepted.\n\n## Errors\n\n```diff\n- import { XRPCError } from '@atproto/xrpc'\n+ import { XrpcError } from '@atproto/lex'\n```\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for **throwing** errors out of\nhandlers — that one is unrelated and stays. A file can legitimately import\nboth (getFeed.ts above does): `XrpcError` describes a failed call out,\n`XRPCError` produces the response going back down.\n\n## Headers map type\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import type { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAlias it — the unaliased `Headers` shadows the global `Headers` type, which the\nsame file usually also needs.\n\n## Data utilities\n\n### JSON ↔ Lex\n\n```diff\n- import { jsonStringToLex } from '@atproto/lexicon'\n+ import { lexParse } from '@atproto/lex'\n\n- const parsed = jsonStringToLex(payload.toString('utf8')) as SubjectActivitySubscription\n+ const parsed = lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(\n+   payload.toString('utf8'),\n+ )\n```\n\n`lexParse` takes a type parameter, so the `as` cast goes away. `stringifyLex` →\n`lexStringify`. See [lex-data](../lex-data/SKILL.md) for `jsonToLex`,\n`lexToJson`, `lexParseJsonBytes`, `parseLexLink`, `parseLexBytes`.\n\n### Datetime strings\n\n```diff\n- createdAt: new Date().toISOString(),\n+ createdAt: currentDatetimeString(),\n\n- indexedAt: someDate.toISOString(),\n+ indexedAt: toDatetimeString(someDate),\n```\n\nBoth come from `@atproto/lex` and return the branded `DatetimeString`, so a\nvalue that reaches a lexicon field is checked at the type level rather than at\nvalidation time.\n\n### CIDs\n\n```diff\n- import { CID } from 'multiformats/cid'\n+ import { type Cid, parseCid } from '@atproto/lex'\n```\n\n`Cid` is an interface. Drop `multiformats` from the package's dependencies once\nno imports remain.\n\n### BlobRef\n\n`BlobRef` is now an interface (a union of `TypedBlobRef` and `LegacyBlobRef`),\nso `instanceof` no longer works — and the hand-written fallbacks that used to\npaper over the two JSON shapes are exactly what the helpers replace:\n\n```diff\n- import { BlobRef } from '@atproto/lexicon'\n+ import { type BlobRef, getBlobCidString } from '@atproto/lex-data'\n\n- export const cidFromBlobJson = (json: BlobRef) => {\n-   if (json instanceof BlobRef) return json.ref.toString()\n-   if (json['$type'] === 'blob') return (json['ref']?.['$link'] ?? '') as string\n-   return (json['cid'] ?? '') as string\n- }\n+ export const cidFromBlobJson = (json: BlobRef): string => getBlobCidString(json)\n```\n\n```diff\n- if (value instanceof BlobRef) { ... }\n+ if (isBlobRef(value)) { ... }\n```\n\n`isBlobRef` validates the CID strictly by default; pass `{ strict: false }` for\nlegacy data. See [lex-data](../lex-data/SKILL.md) for the\n`TypedBlobRef` / `LegacyBlobRef` split.\n\n## Branded types at boundaries\n\nApply `DidString`, `HandleString`, `AtUriString`, `DatetimeString`, `Cid` at\nsignatures, interface fields, and DB schema types — that's where they stop bad\nvalues propagating. Prefer importing them from `@atproto/lex` over\n`@atproto/syntax`.\n\n```diff\n- did: string\n+ did: DidString\n\n- iss: string\n+ iss: DidString | `${DidString}#${string}`\n```\n\nPrefix checks become guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nData arriving from protobuf, the data plane, or Kysely is plain `string`. Cast\nonce at that entry point rather than asserting at every later use:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n```\n\n## Type guards\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { ... }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { ... }\n```\n\n`$isTypeOf` is the behavioral equivalent of a legacy `isX()` guard, so it is the\ndefault when replacing one — this is what the repo migrated to (see\n`packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts`). It checks only\nthe `$type` tag, which is all that matters on an already-validated union, and it\ndoubles as a predicate:\n\n```diff\n- const pref = prefs.find((p) => p.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ const pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` instead only where the data is genuinely untrusted and a\n`$type` tag alone isn't enough of a guarantee — it validates the whole value, so\nit costs more. Treat that as a deliberate upgrade at an entry point, not the\ndefault swap for an `isX()` call.\n\nSee [lex-schema](../lex-schema/SKILL.md) for the full `$`-accessor cheat sheet.\n\n## Tests\n\nTest migration is partial and lags the source migration. The `pds`, `bsky` and\n`ozone` suites mostly still drive `AtpAgent` from `@atproto/api`, and `dev-env`\nexposes both `getAgent(): AtpAgent` and `getClient(): Client` for exactly that\nreason.\n\nA suite still on `AtpAgent` hydrates blobs into **legacy `BlobRef` class\ninstances**, which only `@atproto/lexicon`'s `lexToJson` knows how to flatten.\nSwapping a test helper to `@atproto/lex`'s `lexToJson` leaves those instances\nuntouched and leaks their internal `original` field into snapshots — which reads\nlike a wire-format regression in the service under test. Keep the legacy\nconverter until the suite itself moves to `Client`.\n\nDefault to leaving passing tests alone: during a source migration their value\nis being an unchanged runtime regression check. Two things force a change:\n\n- **A test imports a legacy path you deleted** (`src/lexicon/…`). Point it at\n  the `@atproto/api` equivalent (`ComAtprotoAdminDefs`, `ids`, `$Typed`) — the\n  smallest edit that keeps the test running.\n- **You are deliberately migrating that test file.** Then it moves wholesale:\n  `network.pds.getClient()`, schemas from `src/lexicons/index.js`, `res.body`,\n  and helpers like `getBlobCidString` from `@atproto/lex-data`.\n  `packages/pds/tests/file-uploads.test.ts` is a migrated example.\n\nAvoid half-migrating a file — a suite mixing `agent.api.…` and `client.call(…)`\nfor the same operation is harder to read than either end state.\n\n## Import source summary\n\n| Before                                            | After                                            |\n| ------------------------------------------------- | ------------------------------------------------ |\n| `@atproto/api` (`AtpAgent`)                       | `@atproto/lex` (`Client`)                        |\n| `@atproto/lexicon` (`jsonStringToLex`, `BlobRef`) | `@atproto/lex` (`lexParse`, `BlobRef`)           |\n| `@atproto/lexicon` (`stringifyLex`)               | `@atproto/lex` (`lexStringify`)                  |\n| `@atproto/xrpc` (`XRPCError`)                     | `@atproto/lex` (`XrpcError`, `xrpcSafe`)         |\n| `@atproto/xrpc` (`HeadersMap`)                    | `@atproto/xrpc-server` (`Headers as HeadersMap`) |\n| `multiformats/cid` (`CID`)                        | `@atproto/lex` (`Cid`, `parseCid`)               |\n| `@atproto/syntax` (`DidString`, etc.)             | `@atproto/lex` (prefer this)                     |\n\nDropped imports usually mean dropped `package.json` dependencies\n(`multiformats`, `@atproto/lexicon`, `@atproto/xrpc`), and every package\ntouched needs a changeset entry.\n\n## Related skills\n\n[lex-client](../lex-client/SKILL.md) for the full `Client` API you're migrating\nonto, [lex-setup](../lex-setup/SKILL.md) for codegen and dependency wiring, and\n[lexification-server](../lexification-server/SKILL.md) for the route-definition\nhalf — service packages need both.\n",".agents/skills/lexification-server/SKILL.md":"---\nname: lexification-server\ndescription: >\n  Migrate a service package off the legacy server stack — `lex gen-server`\n  codegen, a committed `src/lexicon/` tree, `server.<ns>.<method>()` route\n  chains, `@atproto/api` inside service code — onto `@atproto/lex` and\n  `@atproto/xrpc-server`. Use when asked to lexify, modernize, or migrate a\n  server package; when a route file imports `Server` or `createServer` from\n  `../lexicon/index.js`; when replacing `ids.*` constants, generated `isX()`\n  guards, `server.xrpc.router`, or `QueryParams`/`OutputSchema` type imports;\n  and when a package still runs `lex gen-server` in its `codegen:lex` script.\n  For the calls a service makes *out* to other services, use\n  lexification-client. For writing new routes on an already-migrated package,\n  use xrpc-server.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating a service package\n\nMoves a package from `@atproto/lex-cli`'s `lex gen-server` output to\n`@atproto/lex` schemas plus `@atproto/xrpc-server` routing.\n\nEvery service in this repo — `packages/pds`, `packages/bsky`, `packages/ozone` —\nis already migrated, so this skill now applies to packages outside the repo, or\nto a stray route chain left behind. **Read the migrated services before\ninventing a pattern.**\n\n## Which skill applies\n\n| Situation                                                    | Skill                                                  |\n| ------------------------------------------------------------ | ------------------------------------------------------ |\n| Package has `src/lexicon/` (singular) and route chains       | **this skill**                                         |\n| Adding a route to a package already on `server.add()`        | [xrpc-server](../xrpc-server/SKILL.md)                 |\n| The same package's outbound `AtpAgent` / `agent.api.…` calls | [lexification-client](../lexification-client/SKILL.md) |\n\nA service migration almost always needs this skill _and_\nlexification-client: one package both serves routes and calls other services.\nDo the server half first — it forces `src/lexicons/` into existence, which the\nclient half then imports from.\n\n## Guiding principles\n\n- **Type-level changes over runtime changes.** Working runtime logic stays;\n  what changes is where types and constants come from. A migration diff that\n  alters behavior is a bug in the migration.\n- **Migrate one namespace of route files at a time**, building in between.\n  A route file is self-contained, so a broken one doesn't block the rest.\n- **Leave test call sites on `AtpAgent`.** Tests double as the regression\n  check that the migration didn't change behavior — swapping the client they\n  use destroys that signal. Tests that import _types or constants_ from the\n  deleted `src/lexicon/` do have to move (details below).\n\n## Order of work\n\n1. **Project config** — swap codegen, deps, gitignore, delete `src/lexicon/`.\n   → [references/project-config.md](references/project-config.md)\n2. **Server bootstrap** — `createServer`, `Server` type, Express mount.\n3. **Route files** — `server.<ns>.<method>(…)` → `server.add(schema, …)`.\n   → [references/routes.md](references/routes.md)\n4. **Constants and guards** — `ids.*`, `isX()`, token exports, `$build`.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n5. **Outbound calls** — [lexification-client](../lexification-client/SKILL.md).\n6. **Branded types** at function/interface/DB boundaries.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n\nSteps 1–2 are a single non-splittable commit (the package won't compile\nin between). Steps 3–6 are incremental.\n\n## Server bootstrap\n\nThe only structural change in the whole migration. `createServer` and `Server`\nstop coming from generated code:\n\n```diff\n- import { createServer } from './lexicon/index.js'\n+ import { createServer } from '@atproto/xrpc-server'\n\n- let server = createServer({\n+ const server = createServer([], {\n    validateResponse: config.debugMode,\n    payload: { /* … */ },\n  })\n\n- app.use(server.xrpc.router)\n+ app.use(server.router)\n```\n\nThe new first argument is an array of legacy `LexiconDoc`s for routes not\nbacked by a generated schema; schema-based setups pass `[]`. Option names\n(`validateResponse`, `payload`, `catchall`, `rateLimits`, `errorParser`) are\nunchanged — see [packages/pds/src/index.ts](../../../packages/pds/src/index.ts).\n\nIn route files, `Server` comes from the same package:\n\n```diff\n- import { Server } from '../../../../lexicon/index.js'\n+ import type { Server } from '@atproto/xrpc-server'\n```\n\n## Import mapping\n\n| Before                                                 | After                                                                |\n| ------------------------------------------------------ | -------------------------------------------------------------------- |\n| `../lexicon` (`Server`, `createServer`)                | `@atproto/xrpc-server`                                               |\n| `../lexicon/lexicons` (`ids`)                          | `../lexicons/index.js` → `.$type` / `.$lxm`                          |\n| `../lexicon/types/…` (record & def types)              | `../lexicons/index.js` → `.Main`, `.ViewRecord`, …                   |\n| `../lexicon/types/…` (`QueryParams`, `OutputSchema`)   | `../lexicons/index.js` → `.$Params`, `.$OutputBody`                  |\n| `../lexicon/types/…` (`isX`, `validateX`)              | `../lexicons/index.js` → `.$isTypeOf`, `.$matches`, `.$safeValidate` |\n| `../lexicon/util` (`$Typed`, `Un$Typed`)               | `@atproto/lex`                                                       |\n| `@atproto/api` (`AtpAgent`)                            | `@atproto/lex` (`Client`)                                            |\n| `@atproto/lexicon` (`jsonStringToLex`, `stringifyLex`) | `@atproto/lex` (`lexParse`, `lexStringify`)                          |\n| `@atproto/lexicon` (`BlobRef`)                         | `@atproto/lex` (`BlobRef`)                                           |\n| `@atproto/xrpc` (`HeadersMap`)                         | `@atproto/xrpc-server` (`Headers`, usually aliased)                  |\n| `@atproto/xrpc` (`XRPCError`, thrown by a _caller_)    | `@atproto/lex` (`XrpcError`)                                         |\n| `multiformats/cid` (`CID`)                             | `@atproto/lex` (`Cid`, `parseCid`)                                   |\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for errors handlers **throw**.\nThe casing distinguishes them: `XRPCError` server-side (concrete, thrown),\n`XrpcError` client-side (abstract, caught). Both can appear in one file.\n\n`@atproto/lex` re-exports `lex-client`, `lex-schema`, `lex-data`, and\n`lex-json`, so `Cid`, `BlobRef`, `lexParse`, and `$Typed` all resolve from it.\n`@atproto/lex-cbor` is **not** re-exported. Importing from either the umbrella\nor the sub-package is fine; match whatever the file already does.\n\n## Pitfalls\n\n- **`$type` vs `$lxm` vs `$nsid`.** `$type` is the record/object type string\n  (`app.bsky.feed.post.$type` → `'app.bsky.feed.post'`), used for collections\n  and union tags. `$lxm` is the method id, used in auth and proxy checks.\n  `$nsid` is the raw NSID, needed for `defs` documents that have no `main`.\n  They can hold identical strings, so a wrong one type-checks and only fails\n  at runtime.\n- **`$matches` vs `$isTypeOf`.** `$isTypeOf` only reads the `$type` tag;\n  `$matches` validates the whole value. Substituting `$isTypeOf` where the\n  data is unvalidated silently accepts malformed records. See\n  [references/types-and-guards.md](references/types-and-guards.md).\n- **`encoding` widening** affects only the bare-handler form of `server.add`,\n  not the object form. Don't sprinkle `as const` everywhere — see\n  [references/routes.md](references/routes.md).\n- **Branded strings arriving from outside TypeScript's view** — protobuf,\n  Kysely rows, `JSON.parse` — are plain `string`. Cast once at the boundary\n  (`as DidString`), never deep in the call graph.\n- **Generated `src/lexicons/` is gitignored.** It won't exist until\n  `pnpm run codegen` (or a build) runs. \"Cannot find module '../lexicons/index.js'\"\n  means codegen hasn't run, not that the import is wrong.\n\n## Tests\n\nTest _call sites_ keep using `AtpAgent`, deliberately (see principles above).\n`dev-env` exposes both `getAgent(): AtpAgent` and `getClient(): Client`, so a\nnew-style client is available when a test genuinely needs one.\n\nWhat must move is anything a test imported from the now-deleted\n`src/lexicon/`. Two valid destinations, both already used in migrated\npackages:\n\n```diff\n- import { ids } from '../../src/lexicon/lexicons'\n- import { isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'\n+ import { com } from '../../src/lexicons/index.js'      // generated schemas\n+ import { $Typed, ComAtprotoAdminDefs, ids } from '@atproto/api'  // legacy equivalents\n```\n\nPrefer `../../src/lexicons/index.js` for new code; `@atproto/api` is the\nlower-churn option when the surrounding test is otherwise untouched. Do not\nadd `@atproto/api` back to a package's dependencies just for this — it is a\ndevDependency in `pds` and only a runtime dependency in `bsky` because of\n`getAgeAssuranceRegionConfig`, unrelated to tests.\n\nBefore writing or restructuring tests, use the\n[testing skill](../testing/SKILL.md) — `pds` and `ozone` are jest, `bsky` is\nvitest.\n\n## Related skills\n\n[xrpc-server](../xrpc-server/SKILL.md) documents the target route API in full\nand is the better reference once a package is migrated.\n[lex-setup](../lex-setup/SKILL.md) owns codegen wiring,\n[lex-schema](../lex-schema/SKILL.md) the `$`-accessors, and\n[lex-data](../lex-data/SKILL.md) branded strings, CIDs, and blobs.\n",".agents/skills/lexification-server/references/project-config.md":"# Project configuration\n\nRead this for step 1 of the migration: replacing `lex gen-server` codegen with\n`lex build`, fixing dependencies, and deleting the legacy generated tree.\n\n`packages/ozone/package.json` is the current \"before\"; `packages/pds` and\n`packages/bsky` are the \"after\".\n\n## Scripts\n\nBefore (ozone — note the hand-maintained glob list and the staleness check):\n\n```jsonc\n\"codegen:lex\": \"lex gen-server --yes ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/*\",\n\"prebuild\": \"[ -f ./src/lexicon/index.ts ] && [ -z \\\"$(find ../../lexicons -newer ./src/lexicon/index.ts -type f -print -quit)\\\" ] || pnpm run codegen:lex\",\n```\n\nAfter:\n\n```jsonc\n\"codegen:lex\": \"lex build --clear --indexFile --lexicons ../../lexicons\",\n\"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n```\n\nTwo things fall out of this, both deliberate:\n\n- No NSID list. `lex build` compiles the whole `lexicons/` tree, so adding a\n  lexicon needs no `package.json` edit. (Small libraries use `--include` to\n  trim output; a service that serves most of a namespace should not.)\n- No staleness guard. `--clear` regenerates unconditionally and the glob\n  `prebuild` picks up every `codegen:*` script the package declares, so a\n  package that also generates protobuf (`bsky`) or templates (`pds`) needs no\n  change to that line. Keep the name `codegen:lex` — root `pnpm codegen` runs\n  `/^(codegen:.+)$/` across the workspace, and a generator hidden inside\n  `prebuild` would be skipped by it.\n\nFull flag reference: [lex-setup skill](../../lex-setup/SKILL.md).\n\n## Dependencies\n\nAdd to `dependencies`:\n\n- `@atproto/lex`\n- `@atproto/xrpc-server` (already present in every service — verify, don't add\n  blindly)\n\nRemove:\n\n- `@atproto/lexicon` — `dependencies`\n- `@atproto/xrpc` — `dependencies`\n- `multiformats` — `dependencies`; CIDs come from `@atproto/lex-data` via\n  `@atproto/lex`\n- `@atproto/lex-cli` — `devDependencies`\n\nAdd the `@atproto/lex-*` sub-packages only when a file imports them directly.\n`pds` lists `lex-cbor`, `lex-data`, and `lex-json` in `dependencies` and\n`lex-document` in `devDependencies` for exactly that reason; `bsky` needs none\nof them and imports everything through the umbrella.\n\nKeep `@atproto/api` only where it is genuinely still used. It stays a\ndevDependency of `pds` (tests) and a runtime dependency of `bsky` (two source\nfiles import `getAgeAssuranceRegionConfig` and age-assurance helpers from it).\n\nEvery package touched needs a `.changeset/` entry — `minor` for a package\nwhose public API changes, `patch` otherwise.\n\n## Gitignore\n\nGenerated output is a build artifact:\n\n```\n# @atproto/lex\nsrc/lexicons\n```\n\nReplaces the `# @atproto/lex-cli` / `src/lexicon` stanza. Note singular →\nplural: `src/lexicon` was the legacy path, `src/lexicons` is the new one. Both\nlines are unanchored patterns, not `/src/lexicons/`.\n\n## Deleting the legacy tree\n\n```sh\nrm -rf ./src/lexicon\n```\n\nSafe because it was gitignored and regenerated. Verify nothing still imports\nit before deleting — in ozone that is ~76 source files plus ~47 test files, so\nexpect the package not to compile until step 3 is done.\n\nDo **not** touch the repo-root `lexicons/` directory. It is canonical\ncommitted source shared by every package and the interop fixtures.\n\n## What does not apply in this monorepo\n\n`lex install`, `manifest.json` / `lexicons.json`, and a `postinstall` hook are\nreal `@atproto/lex` features for consuming schemas published over the network.\nNo package here uses them, because the schemas are committed at the repo root.\nDo not introduce a manifest during a migration.\n",".agents/skills/lexification-server/references/routes.md":"# Migrating route files\n\nRead this for step 3: converting `server.<ns>.<method>(…)` registrations to\n`server.add(schema, …)`, and moving parameter/output types off\n`../lexicon/types/…`.\n\nFor the target API in full — auth verifiers, rate limits, pipethrough, error\nclasses — see the [xrpc-server skill](../../xrpc-server/SKILL.md). This file\ncovers only what the _migration_ changes.\n\n## Registration\n\nThe namespace chain becomes a schema argument. Everything inside the config\nobject is unchanged:\n\n```diff\n- server.app.bsky.feed.getAuthorFeed({\n+ server.add(app.bsky.feed.getAuthorFeed, {\n    auth: ctx.authVerifier.optionalStandardOrRole,\n    handler: async ({ params, auth, req }) => { /* unchanged */ },\n  })\n```\n\nThe bare-handler form drops the wrapper object:\n\n```diff\n- server.com.atproto.identity.resolveHandle(async ({ req, params }) => {\n+ server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n```\n\nThe schema always comes from the generated tree, imported at the top of the\nfile alongside `Server`:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../../lexicons/index.js'\n```\n\nMigrated code overwhelmingly uses the object form (~187 registrations in\n`pds` + `bsky`, versus ~8 bare-handler). Preserve whichever form the file\nalready had; there is no reason to convert between them during a migration.\n\n## `encoding` and literal widening\n\nThe form of the route — bare handler vs `{ handler }` object — is not what\ndecides this. What matters is whether the returned literal is still\n_contextually typed_ by `server.add()`. Returning it directly from a handler\nthat declares at least one parameter is, in both forms, so `as const` is\nunnecessary. Contextual typing is lost when the response is built into an\nintermediate variable first, or when the handler declares no parameters at all\n(`async () => ({ … })`); then `encoding` widens to `string` and the overload\nfails with `TS2769: No overload matches this call`.\n\n```ts\n// contextually typed — `as const` unnecessary in either form\nserver.add(app.bsky.graph.getRelationships, {\n  handler: async ({ params }) => {\n    return { encoding: 'application/json', body: { actor, relationships } }\n  },\n})\n\n// also contextually typed; the `as const` here is redundant but harmless,\n// and is what the real resolveHandle route still carries\nserver.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n  return { encoding: 'application/json' as const, body: { did } }\n})\n```\n\nMany existing routes carry `as const` where it is not strictly needed; it is\nharmless, so leave it alone rather than churning call sites. Full rules live in\nthe [xrpc-server skill](../../xrpc-server/SKILL.md).\n\n`satisfies …$Output` on the returned envelope also pins the literal and works\nin either form, but it is verbose and the repo does not use it. If a return\nneeds an explicit contract, the one real precedent annotates the _body_:\n\n```ts\nbody: { /* … */ } satisfies app.bsky.unspecced.getConfig.$OutputBody,\n```\n\n(`$Output` is the whole `{ encoding, body }` envelope; `$OutputBody` is just\nthe body. Mixing them up produces a confusing excess-property error.)\n\n## Type-only references\n\n```diff\n- import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed'\n+ import { app } from '../../../../lexicons/index.js'\n- type Params = QueryParams\n+ type Params = app.bsky.feed.getAuthorFeed.$Params\n```\n\n| Legacy generated type                | Accessor                                    |\n| ------------------------------------ | ------------------------------------------- |\n| `QueryParams` / `InputSchema` params | `$Params`                                   |\n| `OutputSchema`                       | `$OutputBody`                               |\n| whole handler output                 | `$Output`                                   |\n| `InputSchema` (procedures)           | `$Input`                                    |\n| subscription message union           | `$Message`                                  |\n| record / main object                 | `.Main`                                     |\n| sub-definitions                      | their own name (`.ReplyRef`, `.ViewRecord`) |\n\nBecause the namespace object carries both values and types, one\n`import { app } from '../lexicons/index.js'` replaces every per-method type\nimport in the file. Use `import type` when the file needs only types.\n\n## `$lxm` in auth and proxy code\n\nMethod-id strings that used to come from `ids`:\n\n```diff\n- const aud = computeProxyTo(ctx, req, ids.AppBskyActorGetPreferences)\n+ const aud = computeProxyTo(ctx, req, app.bsky.actor.getPreferences.$lxm)\n```\n\nInside a handler where only the schema object is in scope, `l.getMain(ns)`\nreaches the underlying method definition (`.output.schema`, `.$type`) — used by\n`pds`'s read-after-write helpers and `bsky`'s data-plane record routes.\n\n## Subscriptions\n\nRegister the same way; the handler is an async generator and messages are\nbuilt with `$build`:\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    yield com.atproto.sync.subscribeRepos.commit.$build({ seq, time, ...evt })\n  },\n)\n```\n\n`signal` is an `AbortSignal` on the subscription context — pass it to whatever\nproduces events so client disconnects unwind the generator. See\n[packages/pds/src/api/com/atproto/sync/subscribeRepos.ts](../../../../packages/pds/src/api/com/atproto/sync/subscribeRepos.ts).\n\n## Errors thrown by handlers\n\nNothing to do. `InvalidRequestError`, `AuthRequiredError`, `ForbiddenError`,\n`InternalServerError`, `UpstreamFailureError`, `UpstreamTimeoutError`,\n`NotEnoughResourcesError`, and `MethodNotImplementedError` already come from\n`@atproto/xrpc-server` in unmigrated packages — the legacy generated tree never\nre-exported them. Leave those imports alone.\n\nWhat does change is errors the service _catches_ from its own outbound calls —\nthose move from `@atproto/xrpc`'s `XRPCError` to `XrpcError` from\n`@atproto/lex`. `XrpcError` is abstract: catch and narrow it, never construct\nit. That half belongs to\n[lexification-client](../../lexification-client/SKILL.md).\n\n## Header maps\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAliased because `Headers` collides with the DOM global. The types are not\nidentical: legacy `HeadersMap` was `Record<string, string | undefined>`, the\nnew `Headers` is `Record<string, string>`. Code that relied on assigning\n`undefined` to clear a header needs the key omitted instead.\n",".agents/skills/lexification-server/references/types-and-guards.md":"# Constants, guards, and branded types\n\nRead this for steps 4 and 6: replacing `ids.*`, the generated `isX()` guards,\ntoken constants, and hand-written `$type` literals; then tightening string\ntypes at boundaries.\n\n## `ids.*` → `$type` / `$lxm`\n\nThe `ids` object is gone. Which accessor replaces it depends on what the\nstring is _for_:\n\n```diff\n- import { ids } from '../../../lexicon/lexicons'\n- if (uri.collection === ids.AppBskyGraphList) {\n+ import { app } from '../../../lexicons/index.js'\n+ if (uri.collection === app.bsky.graph.list.$type) {\n```\n\n```diff\n- method === ids.AppBskyFeedGetFeedSkeleton\n+ method === app.bsky.feed.getFeedSkeleton.$lxm\n```\n\n- `$type` — record and object type strings (collections, union tags).\n- `$lxm` — XRPC method ids (auth scopes, proxy targets).\n- `$nsid` — the raw NSID; the fallback for `defs` documents that define no\n  `main` type and therefore have no `$type`.\n\nUse the accessor, not a string literal. A literal is untracked when an NSID is\nrenamed and invisible to the tree-shaking that keeps generated output small.\n\n## `isX()` → `$isTypeOf` or `$matches`\n\nLegacy codegen emitted one `isX()` per definition that checked only the\n`$type` field. The new tree splits that into two accessors, and choosing\ncorrectly matters:\n\n- **`$isTypeOf`** — reads the `$type` tag only. A type predicate, so TS\n  narrows automatically inside `.find()` / `.filter()` / `if`. Correct for\n  discriminating a union whose members were already validated (schema output,\n  hydrated views, anything that came through `$parse`).\n- **`$matches`** — validates the entire value against the schema. Correct\n  when the data's shape is not yet guaranteed: raw records off the firehose,\n  values out of the datastore, third-party input.\n\n`$isTypeOf` is the direct behavioral equivalent of the old `isX()`, and it is\nwhat the real migration used:\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { … }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { … }\n```\n\n```diff\n- repost: isSkeletonReasonRepost(item.reason) ? … : undefined,\n+ repost: app.bsky.feed.defs.skeletonReasonRepost.$isTypeOf(item.reason) ? … : undefined,\n```\n\nHand-written `$type` comparisons collapse the same way:\n\n```diff\n- prefs.find((pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` deliberately, at the point where untrusted data enters —\nin `bsky` that is the record indexing plugins and hydration, in `pds` it is\nre-reading a stored record. Both packages use it a dozen times total against\n~50 uses of `$isTypeOf`.\n\nValidation that returns a result rather than a boolean is `$safeValidate` (or\n`$validate` to throw). See [lex-schema](../../lex-schema/SKILL.md).\n\n## Token values\n\n```diff\n- import { CURATELIST, MODLIST } from '../../../../lexicon/types/app/bsky/graph/defs'\n+ import { app } from '../../../../lexicons/index.js'\n+ const CURATELIST = app.bsky.graph.defs.curatelist.$token\n+ const MODLIST = app.bsky.graph.defs.modlist.$token\n```\n\nUse `$token`, per [STYLE_GUIDE.md](../../../../STYLE_GUIDE.md). It reads as one\nof the `$`-prefixed schema constants (`$type`, `$lxm`, `$nsid`) rather than as a\nfield on the value. Some older call sites use `.value`, which resolves to\nthe same string — replace them if you are editing that file.\n\n## `$build()` for `$type`-carrying values\n\nObject literals that hand-wrote a `$type` field:\n\n```diff\n- return {\n-   $type: 'app.bsky.graph.defs#relationship',\n-   did,\n-   following: subject.following,\n- }\n+ return app.bsky.graph.defs.relationship.$build({\n+   did,\n+   following: subject.following,\n+ })\n```\n\n`$build` fills in `$type` and types the result, so a renamed definition\nbecomes a compile error instead of a silently wrong tag. It is also how\nsubscription messages are emitted (see\n[routes.md](routes.md#subscriptions)).\n\nValues that carry no `$type` need no builder — a type annotation is enough,\nand that is what most def usages become:\n\n```ts\ntype CodeDetail = com.atproto.server.defs.InviteCode\n```\n\n## Centralized type-alias files\n\nA package with a `types.ts` re-exporting from `src/lexicon/types/…` repoints\nit at the generated tree. `packages/bsky/src/views/types.ts` is the live\nexample; copy its conventions rather than inventing names:\n\n```ts\nimport { app, com } from '../lexicons/index.js'\n\nexport type ProfileRecord = app.bsky.actor.profile.Main\nexport const isProfileRecordType = app.bsky.actor.profile.$isTypeOf\n\nexport type StrongRef = com.atproto.repo.strongRef.Main\nexport const validateStrongRef = com.atproto.repo.strongRef.$safeValidate\n```\n\n- Types: `export type Foo = ns.path.TypeName` — `.Main` for the main\n  definition, the def's own name otherwise.\n- Guards: `export const isFooType = ns.path.$isTypeOf`. The `…Type` suffix is\n  intentional; it distinguishes a tag check from a full validation and avoids\n  colliding with the legacy `isFoo` names being replaced.\n- Validators: `export const validateFoo = ns.path.$safeValidate`.\n\nGrouping by NSID prefix with a comment header keeps a 200-line alias file\nnavigable.\n\n## Branded strings\n\nApply at type boundaries — function signatures, interface fields, DB row\ntypes — and leave runtime code alone:\n\n```diff\n- did: string                → did: DidString\n- handle: string             → handle: HandleString\n- handleOrDid: string        → handleOrDid: AtIdentifierString\n- indexedAt: string          → indexedAt: DatetimeString\n- iss: string                → iss: DidString | `${DidString}#${string}`\n```\n\nReplace prefix sniffing with the real guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nPrefer guards over assertion helpers: a guard lets the caller decide what a\nmalformed value means, while an assertion turns it into a 500.\n\nData crossing a boundary TypeScript can't see — protobuf messages, Kysely\nrows, `JSON.parse` — arrives as plain `string`. Cast once, at that entry\npoint:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n\n- qb.where('actor.did', '=', filter.sub!)\n+ qb.where('actor.did', '=', filter.sub! as DidString)\n```\n\nCasting later, after the value has been passed around, means every\nintermediate signature stays `string` and the brand buys nothing.\n\nThese types are re-exported from `@atproto/syntax` unchanged, so\n`import { DidString } from '@atproto/lex'` and\n`from '@atproto/syntax'` are the same type — migrated files use both, and\nfiles already importing other things from `@atproto/syntax` keep doing so.\nFull list and semantics: [lex-data skill](../../lex-data/SKILL.md).\n",".agents/skills/playwright/SKILL.md":"---\nname: playwright\ndescription: >\n  Drive and inspect this repo's OAuth provider UI and Account Manager (source in\n  packages/oauth/oauth-provider-ui, served by the PDS at /account). Use when\n  writing or extending the puppeteer-based UI tests in packages/pds/tests\n  (oauth.test.ts, account-manager.test.ts), when you need the exact visible\n  label, heading, or error string a screen renders before asserting on it, when\n  demoing, navigating or debugging an OAuth sign-in / sign-up / consent flow\n  locally or against production, when exercising account management (password,\n  email, handle, device sessions, deactivation, deletion), or when a question\n  about behavior can only be answered from the rendered page. Use it even when\n  the request sounds like plain test-writing (\"add a test for the reset-password\n  error case\") — getting the assertion strings right is the whole problem.\ndisable-model-invocation: false\n---\n\n# OAuth & Account Manager UI\n\nThe UI lives in [`packages/oauth/oauth-provider-ui`](../../../packages/oauth/oauth-provider-ui) (React 19, TanStack Router, Radix, Lingui i18n) and is served by the PDS through the OAuth provider's middleware. That package has its own [CLAUDE.md](../../../packages/oauth/oauth-provider-ui/CLAUDE.md) covering component structure and i18n — read it before editing the UI itself.\n\nThere is **no Playwright test harness in this repo**: no `playwright` / `@playwright/test` dependency, no `playwright.config.*`, no `.spec.ts` files. The browser tests are jest + **puppeteer** (`puppeteer` is a devDependency of `packages/pds` only). \"Playwright\" in this skill's name refers to driving a browser interactively for discovery and debugging, not to the test runner.\n\n## Get the strings without a browser first\n\nThe tests assert **French** strings, because they set `languages = ['fr-BE', 'fr', 'en-US', 'en']` to exercise locale negotiation. Booting a stack and spoofing a locale just to read a label is slow and easy to get wrong. Two faster sources:\n\n1. **The message catalog** — [`packages/oauth/oauth-provider-ui/src/locales/fr/messages.po`](../../../packages/oauth/oauth-provider-ui/src/locales/fr/messages.po) maps every source string to its French translation, with the defining component in a `#:` comment above it. Grep it for the English string you saw in the JSX and you have the exact assertion text.\n\n   ```\n   #: src/pages/account/(authenticated)/route.tsx\n   msgid \"My Atmosphere Account\"\n   msgstr \"Mon compte Atmosphère\"\n   ```\n\n   Watch for `msgctxt` — the same English word can have several entries (`\"Sign in\"` under `msgctxt \"verb\"` is `\"Se connecter\"`; the noun form differs).\n\n2. **The mock UI dev server** — renders the real components against a fake API, so no PDS, postgres, redis, or docker is involved:\n\n   ```sh\n   cd packages/oauth/oauth-provider-ui\n   pnpm dev:ui   # http://localhost:5174\n   ```\n\n   Pages: `/account-page.html`, `/authorization-page.html`, `/error-page.html`, `/cookie-error-page.html`. Fixtures (accounts, device sessions, branding) are in [`src/mock-api.ts`](../../../packages/oauth/oauth-provider-ui/src/mock-api.ts) — edit them to reach a state the real backend makes awkward (deactivated account, unverified email, many device sessions). It renders in your browser's own locale, so use it for layout and flow, and the `.po` file for the exact French text.\n\nBoot the full stack only when you need real backend behavior: token issuance, email tokens, PLC propagation, handle resolution, deactivation side effects.\n\n## Booting the real stack\n\n`packages/dev-env` wraps startup in [`with-test-redis-and-db.sh`](../../../packages/dev-infra/with-test-redis-and-db.sh), which needs **docker** and **jq** (it falls back to a native postgres/redis if docker is unavailable). Build first — it runs the compiled `dist/bin.js`, not the sources:\n\n```sh\npnpm install && pnpm build       # once, from the repo root\ncd packages/dev-env && pnpm dev  # PDS on :2583, PLC on :2582, appview on :2584\n```\n\nRun `pnpm dev:ts` from the root in another terminal if you're editing TypeScript alongside.\n\n- Account Manager: `http://localhost:2583/account`\n- Seeded accounts ([`packages/dev-env/src/mock/index.ts`](../../../packages/dev-env/src/mock/index.ts)): `alice.test`, `bob.test`, `carla.test`, all `hunter2`.\n- Setting `NODE_ENV=development` (which `pnpm dev` does) makes the OAuth provider log server-side errors and include stack traces in error responses — worth having when a flow fails opaquely.\n\n### Demo OAuth client\n\n```sh\ncd packages/oauth/oauth-client-browser-example\npnpm dev   # http://127.0.0.1:8080\n```\n\nBackend selection is the `env` query parameter, resolved in [`src/constants.ts`](../../../packages/oauth/oauth-client-browser-example/src/constants.ts). It defaults to `NODE_ENV`, which `pnpm dev` sets to `development` — so plain `http://127.0.0.1:8080/` already points at the local stack and `?env=development` is redundant.\n\nOnly `development` targets the local stack — every other value (including `test` and any typo) falls back to `bsky.social` / `api.bsky.app` for handle resolution and the appview. `development` and `test` additionally set `allowHttp` on the OAuth client, which is what lets either talk to a plain-HTTP loopback PDS.\n\n`?env=test` is therefore **not** a local-stack shortcut. It exists for `oauth.test.ts`, which overrides every endpoint explicitly via query params (`pds_operator_url`, `plc_directory_url`, `handle_resolver`, …) and serves its own bundle through `oauthClientAssetsMiddleware` on a random port, not 8080. Used by hand without those overrides it points at production with HTTP allowed — use `development` instead.\n\nAgainst `production`, sign in only with accounts you own — this is a real authorization grant against real infrastructure, not a sandbox.\n\n## Browser automation: confirm what you have\n\nThis repo registers two browser MCP servers in [`.mcp.json`](../../../.mcp.json) — `playwright` (`@playwright/mcp`) and `chrome-devtools` (`chrome-devtools-mcp`), both launched via `pnpx`. Registration is not connection: the server still has to be enabled and started in your harness, and permissions alone don't make tools exist. Run `claude mcp list` and work with what is actually connected rather than assuming `browser_*` tools are available.\n\nIf the Playwright server is registered but has no browser to drive:\n\n```sh\npnpx @playwright/mcp install-browser chrome-for-testing\n```\n\n**Known trap:** the repo root `package.json` declares `devEngines.packageManager: pnpm` with `onFail: \"error\"`, so **any `npx` invocation fails from the repo root** — including the ones MCP plugins use to launch their servers (`npx @playwright/mcp@latest`, `npx chrome-devtools-mcp@…`). Claude Code spawns MCP servers with cwd set to the project root, so a plugin-provided server can fail its health check here while working fine elsewhere. If `claude mcp list` reports a browser server as failed to connect, this is the likely cause; `pnpx` is unaffected, so a server entry using `pnpx` instead of `npx` starts cleanly.\n\nIf no browser MCP is reachable and you can't add one, fall back to the mock UI plus the `.po` catalog for discovery, and to running the puppeteer tests with `headless: false` (the option is already there, commented out, in both test files) for interactive debugging.\n\nTool-name note if you do have the Playwright MCP: `browser_navigate({ url })`, `browser_snapshot({ boxes })`, `browser_click({ element, target })`, `browser_type({ element, target, text })`, `browser_fill_form({ fields })`, `browser_wait_for({ text })`, `browser_console_messages({ level })`, `browser_network_requests({ static })`. `target` takes a ref from a prior snapshot or a CSS selector. Prefer `browser_snapshot` over screenshots — it returns the refs you need to interact, and screenshots don't.\n\n## Writing or extending a UI test\n\nThe two browser test files are [`packages/pds/tests/oauth.test.ts`](../../../packages/pds/tests/oauth.test.ts) and [`packages/pds/tests/account-manager.test.ts`](../../../packages/pds/tests/account-manager.test.ts). Read the neighbouring cases first — they are the best available spec for how these flows behave.\n\n**Discover before you assert.** Reading a component tells you what `<Trans>` wraps; it doesn't tell you which branch renders, what the negotiated locale produces, or what a Radix dialog actually puts in the DOM. Get the real strings from the mock UI, a driven browser, or the `.po` catalog, then write the test.\n\nBoth files share the `PageHelper` wrapper in [`packages/pds/tests/_puppeteer.ts`](../../../packages/pds/tests/_puppeteer.ts) — use it rather than raw puppeteer:\n\n- `goto(url)` / `reload()` — navigate.\n- `assertTitle(text)` — waits for network idle, then asserts `document.title`.\n- `clickOnText(text, tag = 'button')` / `clickOnAriaLabel(label, tag = 'button')` / `clickOn(selector)` — each waits for the element to be _visible_ first.\n- `typeInInput(name, text)` (targets `input[name=\"…\"]`) / `typeIn(selector, text)` — both clear the field first and return the `ElementHandle`, so you can `press('Enter')` on it.\n- `ensureTextVisibility(text, tag = 'p', timeout = 5000)` / `ensureNotification(text)` (`tag = 'div'`).\n- `waitForNetworkIdle()`, `navigationClick(text, tag)` / `navigationAction(fn)` for anything triggering a full navigation, `waitForPopup(fn)` → a `PageHelper` for the popup.\n\n`PageHelper` is `AsyncDisposable`; open pages with `await using page = await PageHelper.from(browser, { languages })` so they close even when a test throws.\n\nTwo recurring gotchas visible in the existing tests:\n\n- `clickOnText` matches on substring via puppeteer's `::-p-text()`, so a label that is a prefix of another (\"Réactiver\" inside \"Réactiver le compte\") hits whichever comes first in DOM order. Target the element directly instead — e.g. `clickOn('[role=\"dialog\"] button[type=\"submit\"]')`.\n- Mailed tokens are obtained by spying on the mailer (`jest.spyOn(network.pds.ctx.mailer, 'sendResetPassword')`) and reading `mock.lastCall`; those calls also assert the negotiated `locale`, which is how the i18n path stays covered.\n\n### Running\n\nThese suites are stateful — each `it` builds on the account state the previous one left behind — so run the whole file, never a single case:\n\n```sh\ncd packages/pds\npnpm test -- tests/oauth.test.ts\n```\n\nGo through `pnpm test`: it wraps jest in the postgres/redis script, and `pretest` installs the Chrome build puppeteer needs.\n\n## Related skills\n\n- [testing](../testing/SKILL.md) — runner choice (vitest vs jest), test file location, tsconfig setup. It routes browser-driven work here; come back to it for anything that isn't a UI test. New tests elsewhere in the repo should be vitest — these two files are jest because `pds` hasn't been migrated.\n",".agents/skills/testing/SKILL.md":"---\nname: testing\ndescription: >\n  Apply this monorepo's testing practices — runner choice (vitest vs jest),\n  where test files belong, tsconfig wiring, and the dev-env fixture used by\n  integration tests. Use before any code search when asked to add, write, or\n  extend tests, add coverage, reproduce a bug with a failing test, choose or\n  configure a test runner, decide where a test belongs, run a single test file,\n  or test a browser-based user flow. Covers unit, integration, and end-to-end\n  tests, and routes UI work to the playwright skill.\ndisable-model-invocation: false\n---\n\n# Testing in this repo\n\n## UI tests: get the assertion strings right first\n\nIf the test drives a UI (`packages/pds/tests/{oauth,account-manager}.test.ts`, or anything using the `puppeteer` `PageHelper`), the assertion strings are the whole problem — they are **French** and branch-dependent, so they can't be guessed from the JSX. Read the playwright skill before asserting on any of them: the fastest source is the `fr` message catalog, with a browser walkthrough as the fallback when the rendered branch is unclear.\n\nFull workflow, `PageHelper` API, and dev-env boot instructions: [playwright skill](../playwright/SKILL.md). Come back here for runner and tsconfig questions.\n\n## Choosing a runner\n\nVitest is the standard and now covers the majority of the repo (~25 packages, ~270 test files) — including `bsky`, all of `packages/lex/*`, `packages/oauth/*`, and the tested `packages/internal/*`. Jest remains in 13 unmigrated packages (~165 files), among them `pds`, `ozone`, `api`, `repo`, and `xrpc-server`. Jest is deprecated: don't introduce it anywhere new, and prefer migrating a file to vitest over deepening its jest usage.\n\nDecide from the package's own config files, not from a memorized list — they are the only drift-proof signal:\n\n1. Package has a `vite.config.*` → **Vitest, configured inside that Vite config** (never a separate `vitest.config.ts`). See [Vite-based packages](references/vitest.md#vite-based-packages).\n2. Package has a `vitest.config.ts` → Vitest. See [references/vitest.md](references/vitest.md).\n3. Package has a `jest.config.cjs` → Jest. See [references/jest.md](references/jest.md).\n4. None of the above → the package has no test setup. Adopt vitest: create `vitest.config.ts` and `tsconfig.test.json` first, following [the setup section](references/vitest.md#adopting-vitest-in-a-new-package), then write the test.\n\nIf the test imports `@atproto/lex` or any `@atproto/lex-*` package, also read the [lex-schema skill](../lex-schema/SKILL.md) — schemas expose `$parse` / `$safeParse` / `$matches`, which make better assertions than hand-rolled shape checks.\n\n## Where tests go\n\n- **Unit tests** sit next to their subject: `foo.ts` + `foo.test.ts`. This is the default whenever the test needs no infra and no cross-package setup.\n- **Integration / end-to-end tests** go in the package's top-level `./tests` folder (`packages/{pds,bsky,ozone}/tests`, `packages/lex/lex/tests`, …). Use it for tests that boot real services, hit a database, or exercise several modules together.\n- **Shared helpers** go in `./tests/_util.ts` (or a `./tests/_util/` directory). The leading underscore keeps them out of glob-based test discovery — see [packages/bsky/tests/\\_util.ts](../../../packages/bsky/tests/_util.ts) and [packages/ws-client/tests/\\_util/](../../../packages/ws-client/tests/_util).\n\nJest packages predate the colocation convention and keep everything in `./tests` — `packages/api/src/age-assurance.test.ts` is the lone exception. Don't relocate existing files just to match the convention.\n\n## Integration tests use the dev-env fixture\n\nAlmost every integration test in `pds`, `bsky`, `ozone`, and `lexicon-resolver` boots a real service constellation through `@atproto/dev-env` rather than mocking. Reach for it instead of hand-wiring servers:\n\n- `TestNetwork.create({ dbPostgresSchema })` — PDS + AppView + bsync + ozone + PLC. Needs postgres and redis. The schema must be unique per test file: it's the isolation boundary (the AppView and ozone databases are derived from it), so parallel files sharing one corrupt each other's data.\n- `TestNetworkNoAppView.create({ pds, plc })` — PDS + PLC only, on a temp-dir store and a mock PLC database. Lighter; use it when the test doesn't touch the AppView. It forwards only the `pds` and `plc` sub-options, so passing a top-level `dbPostgresSchema` here does nothing.\n- `TestPds.create(config, overrides)` — a lone PDS; pair with `TestPlc.create({})` and set `didPlcUrl: plc.url`. The optional second argument is passed to `PDS.create` as `AppContext` overrides (e.g. `{ plcRotationKey }`); neither network class forwards it.\n- `network.getSeedClient()` plus a seed exported from `@atproto/dev-env` (`basicSeed`, `usersSeed`, `quotesSeed`, …) populates accounts and records.\n- `await network.processAll()` flushes the firehose so the AppView has caught up before you assert.\n\nTear down with `afterAll(async () => network?.close())` — the prevailing pattern, and the only option for `TestNetworkNoAppView`, which exposes `close()` but no `Symbol.asyncDispose`. Only `TestNetwork` implements it.\n\nA test that resolves identities (handles, `did:plc`, `did:web`) against localhost must construct its resolver with `fetch: globalThis.fetch` — `@atproto/identity`'s default fetch is SSRF-protected and refuses localhost/private-IP targets silently.\n\nSnapshot assertions go through each package's `forSnapshot()` helper in `tests/_util.ts`, which swaps DIDs, CIDs, and timestamps for stable placeholders so snapshots don't churn on every run. Refresh them with `pnpm test:updateSnapshot` (defined in `bsky`, `pds`, `ozone`, `bsync`).\n\n## TypeScript config for tests\n\nTested packages split their TS config, each part referenced from the package's `tsconfig.json`:\n\n```json\n{\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.config.json\" },\n    { \"path\": \"./tsconfig.build.json\" },\n    { \"path\": \"./tsconfig.test.json\" }\n  ]\n}\n```\n\n- `tsconfig.build.json` — `./src`, excludes `**/*.test.ts`, emits to `./dist`.\n- `tsconfig.test.json` — test code, extending `tsconfig/vitest.tsconfig.json` or `tsconfig/jest.tsconfig.json`. Both set `noEmit` and `composite: false`; the jest one additionally pulls in `@types/jest`. `include` is `[\"./tests\", \"./src/**/*.test.ts\"]` in nearly every package, regardless of runner.\n- `tsconfig.config.json` — root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, …) that neither of the above covers, extending [tsconfig/config.tsconfig.json](../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]`. `exclude` any root file that imports `./src` or `./tests` (e.g. a `jest.setup.ts`), or it drags the whole tree into this stricter program. Present whenever the package has such a file — which is nearly all of them.\n\nThe build is a TS project graph, so `references` is what makes imports resolve. `./tsconfig.build.json` alone covers anything the package already depends on at build time — which is most workspace imports. Add a further entry only for a package the tests import but `src` doesn't: in practice that means `{ \"path\": \"../dev-env/tsconfig.build.json\" }`, and only in the six packages with integration tests (`api`, `bsky`, `lexicon-resolver`, `ozone`, `pds`, `sync`).\n\nCreate `tsconfig.test.json` before writing the first test in a package that lacks one — `packages/did` has jest tests without one, so don't take its layout as the pattern. Exact contents per runner: [vitest](references/vitest.md#adopting-vitest-in-a-new-package) / [jest](references/jest.md#typescript-config).\n\n## Running tests\n\nRun from inside the package directory:\n\n```bash\npnpm test                        # full package suite\npnpm test path/to/file.test.ts   # single file\n```\n\nPackages whose tests need docker infra wrap the runner in a dev-infra script — `bsky`, `pds`, `ozone`, and `sync` use [with-test-redis-and-db.sh](../../../packages/dev-infra/with-test-redis-and-db.sh); `bsync` uses `with-test-db.sh`. Always go through `pnpm test`; invoking `vitest` or `jest` directly skips the script, so postgres and redis aren't running and the suite dies on connection errors. `pds` also offers `pnpm test:sqlite` for a faster loop that skips the docker infra.\n\nFrom the repo root, `pnpm test` runs every package's suite with infra up, and `pnpm test:unit` runs only the projects registered in [vitest.config.ts](../../../vitest.config.ts). That list is not the full set of vitest packages: `bsky` is commented out because it needs infra, and `lexicon-resolver` is simply absent. Run either from its own directory.\n\n## Logs\n\nEvery part of the code base uses pino logging, which is disabled by default. Enable it with `LOG_ENABLED=true` and redirect to a file with `LOG_DESTINATION=...`. For example, to run the full test suite and capture logs:\n\n```bash\nLOG_ENABLED=true LOG_DESTINATION=test.log pnpm test ...\n```\n\nPretty print using:\n\n```bash\ncat test.log | pnpm exec pino-pretty\n```\n\n## Code style\n\n- Always use `using` for spies, mocks and any object that implements `Disposable` or `AsyncDisposable`.\n",".agents/skills/testing/references/jest.md":"# Jest patterns (deprecated)\n\nJest (currently v30) is deprecated here; vitest is the standard. This reference covers adding cases to existing jest files and maintaining them until migration. Don't introduce jest into a package that doesn't already have it — for a fresh package, see [vitest.md](vitest.md).\n\nA package is on jest if it has a `jest.config.cjs` and a `\"test\"` script invoking `jest` with `NODE_OPTIONS=--experimental-vm-modules` (required because the repo is ESM-only). The root [jest.config.cjs](../../../../jest.config.cjs) aggregates them via `projects: ['<rootDir>/packages/*/jest.config.cjs']` — note the single-level glob, so a jest package nested under `packages/lex/` or `packages/oauth/` would not be picked up. Check the package's own config rather than trusting any list.\n\n## TypeScript config\n\nJest packages extend [tsconfig/jest.tsconfig.json](../../../../tsconfig/jest.tsconfig.json), which adds `\"types\": [\"node\", \"jest\"]` on top of the shared node config:\n\n```jsonc\n{\n  \"extends\": [\"../../tsconfig/jest.tsconfig.json\"],\n  \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n  \"compilerOptions\": {\n    \"rootDir\": \".\",\n  },\n  \"references\": [\n    { \"path\": \"./tsconfig.build.json\" },\n    // Integration tests only — see below\n    { \"path\": \"../dev-env/tsconfig.build.json\" },\n  ],\n}\n```\n\n`./tsconfig.build.json` covers the package's own build-time deps and is all most jest packages have. Of the 13, only `api`, `ozone`, `pds`, and `sync` add the `dev-env` entry, because their tests import `@atproto/dev-env` while `src` doesn't. `packages/did` has jest tests but no `tsconfig.test.json` at all — a gap, not a pattern.\n\nThe `include` glob covers colocated `src/**/*.test.ts` even though jest packages barely use them (`packages/api/src/age-assurance.test.ts` is the only one) — leave it as-is so the two runners' configs stay uniform. A package adding custom matchers also needs them declared: [packages/api](../../../../packages/api/tsconfig.test.json) sets `\"types\": [\"jest\", \"./jest.d.ts\"]` to pick up its ambient `jest.d.ts`.\n\n## Writing tests\n\nMost structural conventions from [vitest.md](vitest.md) carry over — `describe`/`it`, `it.each`, fixtures at the top of the file, `using` for spies (jest 30's spies are `Disposable` too). What differs:\n\n| vitest                   | jest                                                                                                                              |\n| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |\n| `vi.fn<FnType>()`        | `jest.fn<FnType>()` (single type param since jest 30)                                                                             |\n| `vi.spyOn` / `vi.mock`   | `jest.spyOn` / `jest.mock`                                                                                                        |\n| `assert()` from `vitest` | `import assert from 'node:assert'` — the prevailing pattern in `pds` tests                                                        |\n| `rejects.toSatisfy(cb)`  | not a jest matcher; use `rejects.toMatchObject({ error: '…' })`, or `const err = await fn().catch((e) => e)` then assert on `err` |\n| `expectTypeOf`           | no equivalent; skip type-level assertions or migrate the file                                                                     |\n\nJest globals are ambient via `@types/jest`, but a minority of files import them explicitly from `@jest/globals`. Match whatever the surrounding files in the package do rather than mixing styles within one package.\n\n## Setup files\n\nTranspilation is `@swc/jest`, not `ts-jest`, with a `moduleNameMapper` that resolves the repo's explicit `.js` import extensions back to `.ts` sources.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests). Jest does accept a path after `--`, but omitting `--` works in both runners — so just use `pnpm test path/to/file.test.ts` and the habit stays correct when the file migrates.\n",".agents/skills/testing/references/vitest.md":"# Vitest patterns\n\nRepo-specific conventions and setup. Assumes working knowledge of vitest itself (currently v4) — only the things this codebase does differently from the defaults are documented here.\n\n## Adopting vitest in a new package\n\n1. Add `vitest` to `devDependencies` and set `\"test\": \"vitest run\"` in `package.json`.\n2. Create `vitest.config.ts` in the package root. Nearly every package in the repo has exactly this and nothing more:\n\n   ```ts\n   import { defineProject } from 'vitest/config'\n\n   export default defineProject({\n     test: {},\n   })\n   ```\n\n   `defineProject` (not `defineConfig`) is what makes the package composable into the root workspace runner. Add `setupFiles`, `testTimeout`, `resolve.alias`, etc. only when the package actually needs them — [packages/bsky](../../../../packages/bsky/vitest.config.ts) and [packages/ws-client](../../../../packages/ws-client/vitest.config.ts) are the only two that do.\n\n   **Exception:** if the package already has a `vite.config.*`, don't create `vitest.config.ts` — see [Vite-based packages](#vite-based-packages).\n\n3. Register the package in the root [vitest.config.ts](../../../../vitest.config.ts) under `test.projects` so `pnpm test:unit` and the VSCode Vitest extension pick it up. Several entries are globs (`packages/lex/*`, `packages/oauth/*`, `packages/internal/*`), so a package placed under one of those directories needs no edit.\n\n   **Skip this step if the tests need dev-infra** (the postgres + redis docker stack). The workspace runner doesn't start dev-infra, so those tests would fail when invoked from the root. That's why `packages/bsky` is commented out of the list — it must run from its own directory via `pnpm test`, which goes through [with-test-redis-and-db.sh](../../../../packages/dev-infra/with-test-redis-and-db.sh). `packages/lexicon-resolver` is also missing from the list, but without a comment and without a dev-infra wrapper on its `test` script, so treat its absence as an oversight rather than a pattern to copy.\n\n4. Create `tsconfig.test.json` extending the shared vitest config (never the jest one, which pulls in `@types/jest`):\n\n   ```json\n   {\n     \"extends\": [\"../../tsconfig/vitest.tsconfig.json\"],\n     \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n     \"compilerOptions\": {\n       \"rootDir\": \".\"\n     },\n     \"references\": [{ \"path\": \"./tsconfig.build.json\" }]\n   }\n   ```\n\n   Adjust the `../../` depth for nested packages (`packages/lex/*` and `packages/oauth/*` need `../../../`). `./tsconfig.build.json` is normally the only reference needed — it already pulls in the package's build-time deps, so a test importing `@atproto/lex-data` from `lex-json` resolves without an extra entry. Add one only for a package the tests import but `src` doesn't; that's `{ \"path\": \"../dev-env/tsconfig.build.json\" }` in the integration-test packages, and little else.\n\n   Extend `include` for other test-only sources: benchmarks (`./src/**/*.bench.ts`, as [lex-json](../../../../packages/lex/lex-json/tsconfig.test.json) does) or ambient declarations (`./src/core-js.d.ts`, as [lex-document](../../../../packages/lex/lex-document/tsconfig.test.json) does).\n\n5. Make sure the package's `tsconfig.json` references `./tsconfig.build.json`, `./tsconfig.test.json`, and — so the new `vitest.config.ts` gets type-checked — `./tsconfig.config.json`. If the package has no `tsconfig.config.json` yet, create one extending [tsconfig/config.tsconfig.json](../../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]` (adjust the `../../` depth for nested packages). See the SKILL's [TypeScript config](../SKILL.md#typescript-config-for-tests) section.\n\n## Vite-based packages\n\nA package with a `vite.config.*` must use vitest, configured **inside that existing Vite config**. Vitest reads `vite.config.*` natively, so a separate `vitest.config.ts` would shadow the package's real build setup (plugins, `resolve.alias`, `optimizeDeps`, `conditions`) and the tests would run against a different module graph than the app. Jest isn't an option at all here — it can't consume the Vite plugin pipeline.\n\nTwo additions to the Vite config, as in [oauth-provider-ui](../../../../packages/oauth/oauth-provider-ui/vite.config.js):\n\n1. `/// <reference types=\"vitest/config\" />` on the **first line** — this types the `test` key, which `defineConfig` from `vite` doesn't know about on its own.\n2. A `test: {}` key in the exported config, kept empty unless an option is genuinely needed.\n\nEverything else is unchanged: `vitest` in `devDependencies`, `\"test\": \"vitest run\"`, root `projects` registration, and a `tsconfig.test.json`.\n\nBecause the tests share the app's Vite config, they inherit its `resolve.alias` — import through the package's own aliases (`#/lib/foo.js`) exactly as production code does, rather than reaching for brittle relative paths.\n\n## Imports\n\nImport test utilities by name from `vitest`; nothing is ambient:\n\n```ts\nimport { assert, describe, expect, it, vi } from 'vitest'\n```\n\nPrefer vitest's `assert` over `node:assert` — it narrows types and reports through vitest's error formatting.\n\n## Labels\n\n`it` and `test` are aliases at runtime, but this repo picks between them by what the label _is_:\n\n- **`it('<description>', …)`** when the label is a sentence describing behavior. Reads as \"it rejects invalid request bodies\".\n- **`test(case, …)`** when the label identifies a specific case: a function reference, a fixture name, a parameterized row. Reads as \"test `parseCid`\".\n\nFor `describe`, pass the function or class **reference** rather than a string when the suite covers a single named export — the label stays correct through renames:\n\n```ts\ndescribe(parseCid, () => { … })\ndescribe(XrpcResponseError, () => { … })\n```\n\nUse a string label for conceptual groups that don't map to one export — an arrow function assigned to a const, a class method, a roundtrip pair (`describe('roundtrip toBase64 <-> fromBase64', …)`). Integration suites under `./tests` span many endpoints and are string-labeled by convention: `describe('pds profile views', …)`.\n\n## Parameterized tests\n\nPrefer `test.each` over a hand-rolled `for` loop. Each row is a case identifier, hence `test` and not `it`:\n\n```ts\ndescribe(isReconnectableClose, () => {\n  test.each([\n    { note: 'normal shutdown', code: CloseCode.Normal, expected: false },\n    { note: 'internal error', code: CloseCode.InternalError, expected: true },\n  ])('$note', ({ code, expected }) => {\n    expect(isReconnectableClose(code)).toBe(expected)\n  })\n})\n```\n\nThe label template is `'$note'` — `$`-prefixed, referring to a property of the row object. `'{note}'` does _not_ interpolate; it renders literally, so every case ends up with the same name.\n\nTo run one suite against several implementations of an interface, the implementation is the case identifier, so `describe.each` takes it — but the inner labels still describe behavior, so they stay `it`:\n\n```ts\ndescribe.each([utf8LenNode, utf8LenCompute])('%o', (utf8Len) => {\n  // some implementations are runtime-optional; assert before the tests read them\n  assert(utf8Len, 'utf8Len implementation should not be null')\n\n  it('computes utf8 string length', () => {\n    expect(utf8Len('a')).toBe(1)\n  })\n})\n```\n\nFall back to `for (const c of cases) { test(c.name, …) }` only when the body shape genuinely can't be expressed as `test.each` — different nesting or a different number of assertions per case.\n\n### Fixture files\n\nImport JSON fixtures with the import attribute and hand the array straight to `test.each`, keyed on a label property:\n\n```ts\nimport fixtures from './data-model-valid.json' with { type: 'json' }\n\ntest.each(fixtures)('$note', (fixture) => { … })\n```\n\nFor the shared text fixtures in `interop-test-files/`, use the existing [`readInteropFile`](../../../../packages/syntax/tests/_utils.ts) helper rather than re-implementing the read-and-filter logic:\n\n```ts\ntest.each(readInteropFile('aturi_syntax_valid.txt'))('%s', (value) => { … })\n```\n\n## Assertions\n\n### `assert` for narrowing\n\n`assert()` narrows types; `expect(…).toBe(true)` does not. Preferring it means the rest of the test can read narrowed properties without a second guard or a cast:\n\n```ts\nassert(result.success)\nexpect(result.body).toEqual({ value: 'hello' }) // result.body is typed here\n```\n\nThe same applies to instance checks before property assertions:\n\n```ts\nassert(err instanceof XrpcFetchError)\nexpect(err.cause).toBeInstanceOf(TypeError)\n```\n\n`expect(result).toMatchObject({ success: true })` is the worst of the three — no narrowing, and a weaker diagnostic on failure.\n\n### Rejections\n\n- `rejects.toThrow()` — the only assertion is that something threw.\n- `rejects.toThrow(message)` — the only assertion beyond that is the message, as a string or regex.\n- `rejects.toSatisfy(…)` — everything else, including checking exactly one non-message property (`err.cause`, `err instanceof X`, `err.code`).\n\nInside a `toSatisfy` callback, a failing `expect()` or `assert()` propagates as the test failure, so the assertions read normally and no try/catch is needed. The callback has to `return true` at the end — that line is only reached when everything passed:\n\n```ts\nawait expect(someAsyncFn()).rejects.toSatisfy((err) => {\n  assert(err instanceof SomeError)\n  expect(err.cause).toBeInstanceOf(TypeError)\n  return true\n})\n```\n\n### Type-level assertions\n\nUse `expectTypeOf` rather than a hand-rolled `const expectType = <T>(_: T) => {}` helper — it compiles to nothing, so it's free at runtime. This repo uses `toEqualTypeOf` for exact matches and `toMatchObjectType` for partial ones. Avoid `toMatchTypeOf`, which upstream deprecated in favor of those two.\n\n```ts\nexpectTypeOf(result.body).toEqualTypeOf<{ blobRef: l.BlobRef }>()\nexpectTypeOf(response.body).toMatchObjectType<{ cursor?: string }>()\n```\n\nFor deliberately invalid arguments, `// @ts-expect-error` with a reason:\n\n```ts\n// @ts-expect-error intentionally passing invalid params\nparams: { limit: 'not-a-number' },\n```\n\n## Mocks and spies\n\nType `vi.fn` with the function type when the test inspects how it was called:\n\n```ts\nconst fetchHandler = vi.fn<FetchHandler>(async () =>\n  Response.json({ value: 'ok' }),\n)\n\nawait xrpc(fetchHandler, testQuery, { params: { limit: 25 } })\n\nexpect(fetchHandler).toHaveBeenCalledOnce()\nconst [path] = fetchHandler.mock.calls[0]\nexpect(path).toContain('/xrpc/io.example.testQuery')\n```\n\nWhen calls don't need inspecting, a plain typed function is clearer than a mock: `const fetchHandler: FetchHandler = async () => …`.\n\n### Module mocks\n\nMocking a node built-in usually needs both the default and the named exports, since callers may use either form ([lexicon-resolver](../../../../packages/lexicon-resolver/tests/lexicon.test.ts) does this for DNS):\n\n```ts\nvi.mock('node:dns/promises', () => {\n  const mock = { resolveTxt: vi.fn() }\n  return { default: mock, ...mock }\n})\n```\n\nTo override only part of a module, spread the actual one:\n\n```ts\nvi.mock('./const.js', async () => {\n  const actual = await vi.importActual<Record<string, unknown>>('./const.js')\n  return { ...actual, KWS_V2_COUNTRIES: new Set(['AA']) }\n})\n```\n\nNote that `bsky` integration tests mock the **compiled** path (`../../dist/api/…`), not `../../src/…`, because the service under test is loaded from `dist`. Match whatever the module graph actually resolves, or the mock silently won't apply.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests) — in particular, don't put `--` before a file path.\n",".agents/skills/update-chat-lexicons/SKILL.md":"---\nname: update-chat-lexicons\ndescription: >\n  Sync `chat.bsky.*` Lexicon JSON from the private `bluesky-social/chat` repo\n  into this monorepo, regenerate the code derived from it, and add a changeset.\n  Use whenever asked to update, sync, pull, refresh, or copy chat lexicons, to\n  pick up new or changed chat convo, group, moderation, or notification\n  definitions, or to reconcile `lexicons/chat/` with the chat service —\n  including when the request is just \"update chat lexicons\".\ndisable-model-invocation: false\n---\n\n# Update chat lexicons\n\n`lexicons/chat/bsky/**` is a mirror. The source of truth is the separate\n`bluesky-social/chat` repo, so an update is a copy _plus_ codegen _plus_ a\nchangeset — the copy on its own leaves the generated clients stale.\n\n## 1. Refresh the source checkout\n\nThis assumes a sibling clone at `../chat`. Update it first: step 2 copies with\n`--delete`, so syncing from a checkout that is behind will delete lexicons that\nstill exist upstream.\n\n```bash\ngit -C ../chat pull --ff-only\n```\n\nIf `../chat` doesn't exist, `bluesky-social/chat` is private and not\nweb-fetchable — clone it with `gh repo clone bluesky-social/chat ../chat`, or\nask the user where their checkout lives and substitute that path below. Don't\nimprovise a source from the destination; there would be nothing to sync.\n\n## 2. Copy\n\n```bash\nrsync -a --delete ../chat/lexicons/chat/ ./lexicons/chat/\n```\n\nOnly `lexicons/chat/` is mirrored. The chat repo also carries `app/`, `com/`,\nand `tools/` lexicons that diverge from this repo's — never sync those.\n\nCheck `git status` immediately. Unexpected deletions almost always mean the\nsource checkout is behind, not that definitions were dropped upstream.\n\n## 3. Regenerate\n\n```bash\npnpm codegen\n```\n\nRun this from the repo root, and use `pnpm codegen` rather than `pnpm build`:\n`packages/api` and `packages/ozone` regenerate only when a lexicon file is\n_newer_ than their generated index, and `rsync -a` preserves the source's\nmtimes — so a build can decide nothing changed and silently keep stale types.\n`pnpm codegen` invokes the generators unconditionally, building the codegen\ntooling itself first.\n\nBoth generated trees are gitignored, so the commit diff shows only JSON. The\nreal blast radius stays invisible until you build.\n\n## 4. Changeset\n\nAdd a file under `.changeset/` with a three-word kebab-case name:\n\n```markdown\n---\n'@atproto/api': patch\n---\n\nupdate chat lexicons\n```\n\nA patch on `@atproto/api` alone is the established convention here (see\n`git log --oneline -- lexicons/chat`). `@atproto/ozone` also generates from\nthese lexicons but picks up an automatic dependency bump, so don't list it by\nhand. Use `minor` instead if the sync removes or renames a definition — that\nbreaks the published types.\n\n## 5. Verify\n\n```bash\n(cd packages/api && pnpm build && pnpm test)\n(cd packages/ozone && pnpm build)\n```\n\nOzone's build typechecks its route handlers against the regenerated server\ntypes, which is where a removed or narrowed chat definition actually surfaces.\n\nThen review the diff and commit. Before opening a PR, run the full checks from\nthe root: `pnpm build --force && pnpm verify`.\n",".agents/skills/xrpc-server/SKILL.md":"---\nname: xrpc-server\ndescription: >\n  Build or edit routes on an AT Protocol service that uses\n  `@atproto/xrpc-server` — `packages/pds`, `packages/bsky`, or any package whose\n  handlers call `server.add()`. Use when adding or changing an endpoint, wiring\n  `createServer` into Express, reading params/input/auth in a handler, returning\n  JSON, binary, streamed, or empty responses, setting response headers, throwing\n  XRPC protocol errors, applying per-route rate limits or body limits, or\n  exposing a WebSocket subscription. To migrate a service off the legacy\n  generated server stack, use the lexification-server skill instead. For calls\n  the service makes *out* to other services, use the lex-client skill.\ndisable-model-invocation: false\n---\n\n# `@atproto/xrpc-server`\n\nThe server half of the AT Protocol XRPC stack. Service code creates a `Server`\nand registers endpoints by passing generated lexicon schemas to `server.add()`.\n\nImport it directly — it is **not** re-exported by `@atproto/lex`:\n\n```ts\nimport { InvalidRequestError, type Server } from '@atproto/xrpc-server'\n```\n\nThis is the server package the services actually use — `@atproto/lex-server` is\na separate package that no service in this repo consumes. `packages/pds`,\n`packages/bsky` and `packages/ozone` are all fully on `server.add()`.\n\nWatch the casing: `XRPCError` is this package's server-side class, thrown in\nhandlers. `XrpcError` is the _client_-side class from `@atproto/lex-client`.\n\n## Creating the server\n\n```ts\nimport { createServer } from '@atproto/xrpc-server'\n\nconst server = createServer([], {\n  validateResponse: config.debugMode,\n  payload: {\n    jsonLimit: 100 * 1024,\n    textLimit: 100 * 1024,\n    blobLimit: 5 * 1024 * 1024,\n  },\n})\n\napp.use(server.router) // Express\n```\n\nThe first argument is an array of legacy `LexiconDoc`s — pass `[]` for\nschema-based setups. It is not middleware. Other `Options` worth knowing:\n`catchall` (a fallback `RequestHandler`, used by the PDS for proxying),\n`errorParser` (map non-`XRPCError` throwables onto XRPC errors before they are\nrendered), and `rateLimits` (global/shared limiter wiring).\n\n`server.router` is the Express app. Legacy generated servers expose\n`server.xrpc.router` instead — if you see that, you are in legacy code.\n\nWebSocket upgrades are wired up automatically when the server's router is\nmounted on an app that later calls `.listen()`; there is nothing extra to do\nfor subscriptions beyond registering them.\n\n## Registering a route\n\n`server.add(schema, handlerOrConfig)`. The schema is the generated lexicon\nobject, e.g. `com.atproto.identity.resolveHandle`.\n\nBare-function form when the route needs nothing but a handler:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../lexicons/index.js'\n\nexport default function (server: Server, ctx: AppContext) {\n  server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n    const did = await resolve(params.handle)\n    return { encoding: 'application/json', body: { did } }\n  })\n}\n```\n\nObject form for anything else — `auth`, `rateLimit`, `opts`:\n\n```ts\nserver.add(app.bsky.draft.createDraft, {\n  auth: ctx.authVerifier.standard,\n  handler: async ({ input, auth }) => {\n    const actorDid = auth.credentials.iss\n    if (atLimit)\n      throw new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\n    return { encoding: 'application/json', body: { draft } }\n  },\n})\n```\n\nRegistration is conditional-friendly: it is normal to `return` early when a\ndependency is absent (`if (!ctx.bskyAppView) return`), or to register a\ndifferent handler for the same NSID on each branch of an `if`.\n\n### Handler context\n\n`{ params, input, auth, req, res, resetRouteRateLimits }`. Queries read\n`params`, procedures read `input.body`; `req`/`res` are Express objects, useful\nfor reading request headers or setting response headers directly.\n\nThere is **no `signal`** on a method handler — only subscription handlers get\none. `resetRouteRateLimits()` clears the consumed points for this route,\ne.g. after a successful login.\n\n### Route options and rate limits\n\n```ts\nserver.add(com.atproto.repo.createRecord, {\n  auth: ctx.authVerifier.authorization({ authorize: () => {} }),\n  rateLimit: [\n    {\n      name: 'repo-write-hour',\n      calcKey: ({ auth }) => auth.credentials.did,\n      calcPoints: () => 3,\n    },\n  ],\n  opts: { jsonLimit: 1_000_000 },\n  handler: async ({ input, auth }) => {\n    /* ... */\n  },\n})\n```\n\n`rateLimit` is one entry or an array; `{ name }` references a shared limiter\ndeclared in `createServer`'s `rateLimits.shared`, while `{ durationMs, points }`\ndeclares a route-local one. `opts` is `RouteOptions` — `jsonLimit`, `textLimit`,\n`blobLimit`, `paramsParseLoose` — overriding the server-wide `payload` defaults.\n\n## Return values\n\nA JSON response is `{ encoding, body }`, optionally with `headers`:\n\n```ts\nreturn {\n  encoding: 'application/json',\n  body: result,\n  headers: { 'server-timing': serverTimingHeader([timerSkele, timerHydr]) },\n}\n```\n\nOther shapes the server understands:\n\n| Return                           | Effect                                                                   |\n| -------------------------------- | ------------------------------------------------------------------------ |\n| nothing (`undefined`)            | 200 with an empty body — correct for lexicons with no `output`           |\n| `{ encoding, body: Readable }`   | streamed binary response                                                 |\n| `{ encoding, buffer, headers? }` | `HandlerPipeThroughBuffer`                                               |\n| `{ encoding, stream, headers? }` | `HandlerPipeThroughStream` — what the PDS `pipethrough()` helper returns |\n\nBinary example, from `packages/pds/src/api/com/atproto/sync/getBlob.ts`:\n\n```ts\nres.setHeader('content-security-policy', `default-src 'none'; sandbox`)\nreturn {\n  encoding: found.mimeType || ('application/octet-stream' as const),\n  body: found.stream,\n}\n```\n\nBodies are validated against the schema's output unless the server was\nconstructed with `validateResponse: false`.\n\n### When `encoding` needs `as const`\n\nReturning the object literal _directly_ from a handler that declares at least\none parameter is contextually typed by `server.add()`, so `encoding:\n'application/json'` is already narrow and needs nothing — this is what most\nhandlers in `packages/bsky/src/api/` do.\n\nContextual typing is lost in two cases, and then `'application/json'` widens to\n`string` and no longer matches the schema:\n\n- the response is built into an intermediate variable before being returned\n- the handler declares no parameters at all (`async () => ({ ... })`)\n\nFix either with `as const` on the literal, or by anchoring the whole object with\n`satisfies app.bsky.graph.getRelationships.$Output`. A\n`Promise<…$Output>` return-type annotation does **not** fix the\nintermediate-variable case — it produces an assignability error instead.\n\n## Errors\n\nThrow an `XRPCError` subclass. Constructors are\n`(message?, errorName?, options?)`; `errorName` should match an error declared\nin the lexicon, and `options` is standard `ErrorOptions` so causes can be\nchained:\n\n```ts\nthrow new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\nthrow new InvalidRequestError('Unable to resolve handle', undefined, {\n  cause: err,\n})\n```\n\n| Class                       | Status |\n| --------------------------- | ------ |\n| `InvalidRequestError`       | 400    |\n| `AuthRequiredError`         | 401    |\n| `ForbiddenError`            | 403    |\n| `InternalServerError`       | 500    |\n| `MethodNotImplementedError` | 501    |\n| `UpstreamFailureError`      | 502    |\n| `NotEnoughResourcesError`   | 503    |\n| `UpstreamTimeoutError`      | 504    |\n\n`RateLimitExceededError` (429) also exists but takes a `RateLimiterStatus` as its\nfirst argument and is thrown by the rate limiter, not by handlers.\n\nEach subclass overrides `Symbol.hasInstance` to match on the response type\nrather than the prototype chain, so `err instanceof InvalidRequestError` is true\nfor _any_ `XRPCError` with a 400 type, however it was constructed.\n\n`ErrorResult` (`{ status, error?, message? }`) is the non-throwing counterpart,\nreturned by auth verifiers; `excludeErrorResult(v)` converts one back into a\nthrown `XRPCError`.\n\n## Schema accessors inside handlers\n\nThe schema object is in scope — use its accessors instead of NSID string\nliterals (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md)):\n\n```ts\nconst lxm = app.bsky.actor.getPreferences.$lxm // 'app.bsky.actor.getPreferences'\nconst aud = computeProxyTo(ctx, req, lxm)\npermissions.assertRpc({ aud, lxm })\n```\n\n| Accessor        | Use                                                                  |\n| --------------- | -------------------------------------------------------------------- |\n| `$lxm`          | lexicon method id, for auth scopes and proxy targets                 |\n| `$type`         | record type, e.g. `uri.collection === app.bsky.feed.post.$type`      |\n| `$token`        | a token def's string value (`app.bsky.graph.defs.curatelist.$token`) |\n| `$build({...})` | construct a value with its `$type` set                               |\n\n`$build` is how union members are constructed:\n\n```ts\napp.bsky.graph.defs.notFoundActor.$build({ actor, notFound: true })\n```\n\nFor shapes that carry no `$type`, just annotate or `satisfies` the exported type\n(`com.atproto.server.defs.InviteCode`).\n\n## Type-only references\n\n```ts\ntype Params = app.bsky.feed.getAuthorFeed.$Params\ntype Output = app.bsky.feed.getAuthorFeed.$Output // { encoding, body }\ntype Body = app.bsky.feed.getAuthorFeed.$OutputBody // just the body\ntype PostRecord = app.bsky.feed.post.Main\ntype PostView = app.bsky.feed.defs.PostView\n```\n\n`$OutputBody` is the one you want for helper functions that compute a response\nbody; `$Output` includes the `encoding` wrapper. Inputs mirror this with\n`$Input` / `$InputBody`, and subscriptions use `$Message`.\n\n## Subscriptions\n\nRegister a subscription schema with an async generator. Yield `$build()`-ed\nmessages; the server frames and (unless `validateResponse: false`) validates\neach one. `signal` aborts when the client disconnects — thread it into whatever\nproduces events so the generator unwinds.\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    const { cursor } = params\n    const curr = await ctx.sequencer.curr()\n    if (cursor !== undefined && cursor > (curr ?? 0)) {\n      throw new InvalidRequestError('Cursor in the future.', 'FutureCursor')\n    }\n    for await (const evt of outbox.events(cursor, signal)) {\n      yield com.atproto.sync.subscribeRepos.commit.$build({\n        seq: evt.seq,\n        time: evt.time,\n        ...evt.evt,\n      })\n    }\n  },\n)\n```\n\nAnnotating the generator's return type keeps errors on the offending `yield`\nrather than on the whole `server.add()` call. Throwing before the first yield\nrejects the connection with that error. The subscription context is\n`{ params, auth, req, signal }` — and `req` here is a raw Node\n`IncomingMessage`, not an Express `Request`.\n\n`packages/pds/src/api/com/atproto/sync/subscribeRepos.ts` is the only\nsubscription in the repo and the reference implementation.\n\n## File layout\n\nHandlers live under `src/api/<nsid-path>.ts`, one file per method, filename\nmatching the method name in camelCase (the documented exception to the\nkebab-case rule), each default-exporting a registrar:\n\n```\npackages/bsky/src/api/\n  app/bsky/feed/getAuthorFeed.ts   ← export default function (server, ctx) { server.add(...) }\n  com/atproto/identity/resolveHandle.ts\n  index.ts                         ← imports each module and calls it\n```\n\n`src/api/index.ts` is a flat list of imports followed by a flat list of\n`handler(server, ctx)` calls; add new routes in both places.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  `server.add()`, and their `$`-accessors. Needed alongside this skill for\n  almost any route work.\n- **[lex-data](../lex-data/SKILL.md)** — branded strings, `BlobRef`, CBOR, and\n  datetimes at route boundaries.\n- **[lex-client](../lex-client/SKILL.md)** — when the service also calls _out_\n  to other services.\n- **[lexification-server](../lexification-server/SKILL.md)** — migrating an\n  existing service off the legacy `lex gen-server` stack onto the patterns\n  above.\n- **[lex-setup](../lex-setup/SKILL.md)** — lexicon install/build configuration.\n- **[testing](../testing/SKILL.md)** — runner choice and test placement when\n  testing route handlers.\n","AGENTS.md":"# AGENTS.md\n\n## Repository overview\n\nThis is the TypeScript reference implementation of [AT Protocol](https://atproto.com), the decentralized social media protocol behind Bluesky. It is a pnpm monorepo (runtime floor Node.js ≥22) containing client libraries, schema/codegen tooling, and the two main service implementations: the Personal Data Server (PDS) and the `app.bsky` AppView.\n\nWorkspace layout (see [pnpm-workspace.yaml](./pnpm-workspace.yaml) and [tsconfig.json](./tsconfig.json)):\n\n- [packages/\\*](packages/) — top-level libraries: `api`, `common`, `crypto`, `identity`, `lexicon`, `repo`, `syntax`, `xrpc`, `xrpc-server`, `pds`, `bsky`, `bsync`, `ozone`, `dev-env`, `dev-infra`, etc.\n- [packages/lex/\\*](packages/lex/) — the modern type-safe Lexicon SDK family (`@atproto/lex`, `lex-builder`, `lex-cbor`, `lex-client`, `lex-data`, `lex-document`, `lex-json`, `lex-resolver`, `lex-server`, `lex-schema`, `lex-installer`, `lex-password-session`). New service code should use this in preference to the older `@atproto/api` / `@atproto/lexicon` / `@atproto/xrpc` / `@atproto/lex-cli` stack — see the `lex-*` skills listed under [Codegen](#codegen).\n- [packages/oauth/\\*](packages/oauth/) — OAuth client/provider implementations and JWK helpers.\n- [packages/internal/\\*](packages/internal/) — `@atproto-labs/*` internal shared utilities (fetch, handle/identity/DID resolvers, simple-store, pipe, xrpc-utils).\n- [services/{pds,bsky,bsync,ozone}](services/) — thin runtime wrappers; the actual implementation code lives in `packages/{pds,bsky,bsync,ozone}`.\n- [lexicons/](lexicons/) — the canonical JSON Lexicon schemas for `com.atproto.*` and `tools.ozone.*` **only** (this repo is their source of truth). Other namespaces (`app.bsky.*`, `chat.bsky.*`, …) are vendored per-package from the `bsky` repo or the network — see [Codegen](#codegen). [lexicons-overrides/](lexicons-overrides/) holds local override documents shared across packages.\n- [interop-test-files/](interop-test-files/) — language-neutral protocol conformance fixtures, copied from [bluesky-social/atproto-interop-tests](https://github.com/bluesky-social/atproto-interop-tests/) (their canonical source, shared across SDKs). Don't edit unless changing protocol-level behavior; any edit must also be contributed upstream.\n\n## Common commands\n\nWhole-repo verification commands (from root):\n\n```bash\n# build:tooling → prebuild (codegen) → build:ts → build:ui\npnpm run build [--force]\n\n# TypeScript-only build & typecheck\npnpm run build:ts [--force]\n\n# force code generation (lexicons, protobuf, i18n, utils, etc.)\npnpm run codegen\n\n# style + lint\npnpm run verify\npnpm run style:fix # Avoid, prefer per-file formatting & linting\n\n# lint specific files only\npnpm exec eslint --fix <path>\n\n# format specific files only\npnpm exec prettier --write <path>\n```\n\nPer-package work — **always run from inside the package directory**, not from the root:\n\n```bash\ncd packages/<pkg>\npnpm run build\npnpm run test\n```\n\nEvery package ships a `tsconfig.build.json` (composite, with explicit `references` to its workspace deps), and nearly every package with tests adds a `tsconfig.test.json` for the test sources. A package with root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, `lingui.config.ts`, `bin.js`, …) also adds a `tsconfig.config.json` so those files are type-checked — it extends [tsconfig/config.tsconfig.json](tsconfig/config.tsconfig.json) and `include`s the package's own `./*.{ts,js,cjs,mjs}`. When you add such a file to a package that has none, create `tsconfig.config.json` and reference it from the package `tsconfig.json` (`exclude` any root file that pulls `./src` or `./tests` into the program, e.g. a `jest.setup.ts`). The root `tsconfig.json` is a project-graph aggregator only.\n\nAvoid `pnpm run style:fix` (whole-repo prettier) unless the user explicitly asks for a repo-wide formatting pass.\n\nRun the formatter/linter once the work is complete: when about to commit, or when the user says the change is done, not after every small edit.\n\n## Tests\n\nFor Ozone integration tests, verify state through existing endpoints (for example, `queryEvents` for appeal events and `queryReports` for report state). Use direct database access only for fixtures that cannot be created through an API, or for tests specifically exercising database behavior.\n\nBefore writing or extending any test, invoke the `testing` skill ([.agents/skills/testing/SKILL.md](.agents/skills/testing/SKILL.md)). It covers runner selection (vitest vs jest), file layout, and tsconfig setup. For browser-driven UI tests, or for demoing/debugging the OAuth flows or the Account Manager interface, invoke the `playwright` skill ([.agents/skills/playwright/SKILL.md](.agents/skills/playwright/SKILL.md)) instead.\n\n## Codegen\n\nThe repo-root [lexicons/](lexicons/) holds the canonical `com.atproto.*` / `tools.ozone.*` schemas **only** — this repo is their source of truth. Every other namespace (`app.bsky.*`, `chat.bsky.*`, `site.standard.*`, …) is vendored **per package**: each package lists the NSIDs it needs and where to resolve them (the root `lexicons` directory, the `bsky` GitHub repo, the network, or the shared [lexicons-overrides/](lexicons-overrides/)) in its own committed `lexicons.json`. A package's `prebuild` runs `lex install --ci` (resolving JSON into its gitignored `./lexicons`) then `lex build` (generating the gitignored `./src/lexicons` TypeScript tree that code imports). **Full how-to: the [\"Working with lexicons\"](CONTRIBUTING.md#working-with-lexicons) section of CONTRIBUTING.md and the `lex-setup` skill.**\n\n- **Add/refresh an external lexicon in a package:** `cd packages/<pkg> && pnpm lex:install <nsid>` (adds it plus transitive deps); `pnpm lex:update` refreshes installed ones.\n- **Edit a canonical lexicon:** change it under [lexicons/](lexicons/), then run `pnpm lex:update-local` from the repo root to propagate the new CID into every dependent package's `lexicons.json`. (After editing a `.proto` file instead, run `pnpm codegen` from the root.)\n\nFor working with the generated SDK, invoke the focused skills under [.agents/skills/](.agents/skills/): `lex-setup` (lexicons.json / resolvers / codegen wiring), `lex-schema` and `lex-data` (schemas and values), `lex-client` (calls out), `xrpc-server` (defining server routes), and `lexification-client` / `lexification-server` (migrating off the legacy stack). To sync `chat.bsky.*` schemas from the chat repo, use `update-chat-lexicons`.\n\n## Architecture notes\n\n- **Lexicons are the contract.** Lexicon schemas drive both client types and server route validation. Service packages don't hand-write XRPC method signatures — they import the generated definitions from their `src/lexicons/` directory (gitignored; regenerated from the package's `lexicons.json` — see [Codegen](#codegen)).\n- ([packages/pds](packages/pds)) — a single-tenant atproto server: account management, repo storage (kysely-over-sqlite), actor storage (kysely-over-postgres), email, OAuth provider, blob storage. Runtime entry point is [services/pds](services/pds); production code is in `packages/pds/src`.\n  - Outbound requests to a URL resolved from a DID document are untrusted. Make them with `ctx.safeClient(url).xrpc(…)`, never a bare `xrpc(url, …)`, which uses the global fetch and bypasses the https-only/unicast-only checks. (`ctx.proxyAgent` is a separate thing — an undici `Dispatcher` used by pipethrough.)\n- ([packages/repo](packages/repo)) — **Every** MST traversal that can visit more than one child of a node (full-tree walks, diffs, block collection) MUST call `node.markVisited(seen)` on each node it enters, with a fresh `CidSet` per traversal. Never add one that skips it. Single-path lookups are exempt. See [packages/repo/AGENTS.md](packages/repo/AGENTS.md).\n- ([packages/bsky](packages/bsky)) — read-side service for `app.bsky.*` queries (timelines, profiles, feed generators, hydration pipeline, GraphQL-like view composition). Talks to PDSes via XRPC and to `bsync` via Connect-RPC (protobuf in `packages/bsky/proto`). Runtime entry point in [services/bsky](services/bsky).\n  - `app.bsky.feed.getFeed` forwards passthrough headers to a feed generator only when its record's service DID is in `bskyFeedgenDids`; Iris routing overrides do not grant permission.\n- ([packages/bsync](packages/bsync)) — internal service for cross-AppView synchronization (mutes, notifications). Connect-RPC interface.\n- ([packages/ozone](packages/ozone)) — moderation service for `tools.ozone.*`.\n  - Design viewer queries for hundreds of millions of moderation events and tens of millions of reports. Batch hydration, bound returned rows, and check query plans against existing indexes before proposing new ones. Keep viewer projections explicit and test ownership and private-field isolation through the API.\n  - Large report-table backfills run in the daemon as bounded primary-key batches. Lock a `job_cursor` row and commit each batch; schema migrations should add nullable columns only. After legacy report writers have rolled off, run `UPDATE job_cursor SET cursor = NULL WHERE job = 'report_reporter_did_backfill'` for a catch-up sweep. Advance past rows with missing or invalid source events and log their counts so they cannot stall the worker.\n- ([packages/dev-env](packages/dev-env)) — boots a full PDS + AppView + bsync + plc + ozone constellation in-process for tests and the `make run-dev-env` REPL. Most integration tests in `pds`/`bsky`/`ozone` use it as a fixture builder.\n\n## Conventions\n\n**Code style rules live in [STYLE_GUIDE.md](./STYLE_GUIDE.md)** — imports, typing, dependencies, change scope, and formatting. Read it before writing code. The rest of this section covers repository mechanics only.\n\n- Node ≥22 runtime floor; local nvm users default to Node 24 (`.nvmrc`). CI changeset/build/verify jobs read `devEngines.runtime.version` from `package.json`; tests stay on Node 22 for compatibility, with a security-patched minimum in `.github/workflows/repo.yaml`. Use `node --enable-source-maps` for production-style runs.\n- TypeScript compilation uses the native TS7 `tsc` (the standard `typescript` package). There is no per-package `typescript` devDependency — it is hoisted at the root. Note TS7 has no stable programmatic API yet; tools needing one must pin TS6.\n- **Every package touched by a change needs a changeset entry.** Add a file under [.changeset/](.changeset/) listing each modified package with an appropriate bump level (pre-v1 breaking changes are `minor` and everything else `patch`, post-v1 `major` for breaking changes, `minor` for new public API, `patch` otherwise). Dependency-only bumps are generated automatically — don't list them by hand. Create that file with `pnpm changeset`.\n- **Never buffer an unbounded stream from outside the process.** Decoding or buffering a request/response body that did not originate locally requires an explicit size bound on the _decoded_ bytes — a wire-size cap does not bound a compressed payload. Pass `maxSize` (and a `maxSizeError` for the caller's error type) to `createDecoders` and `pipeline` the result — it bounds every decoding stage, and must be used even when there is no content-encoding (see `packages/xrpc-server/src/util.ts`) — rather than passing a decoded stream straight to `streamToNodeBuffer`.\n\n## Agent files\n\nAgent files — this `AGENTS.md`, the skills under [.agents/skills/](.agents/skills/), and any package-level equivalents — are part of the codebase and must stay in sync with it.\n\n- Keep working plans, handoffs, review reports, query-plan captures, and other session scratch files outside the repository, such as in the workspace directory containing the checkout. Do not add repository ignore rules for them. Never commit them. Before committing, inspect the staged file list for working documents; put only durable project guidance in tracked documentation.\n\n- These files capture **important architectural patterns and conventions** — the non-obvious rules, invariants, and cross-cutting decisions an expert needs that are _not_ evident from reading the code itself. They are not a changelog of behavior, and must not restate anything a reader can infer directly from the code (a route's signature, a function's arguments, what a module exports). Most changes need no update: don't document a new route, metric, migration, or test that simply re-applies an existing pattern. Reach for an edit only when a change establishes, meaningfully alters, or removes such an architectural pattern, and prefer doing it when wrapping up rather than mid-task. If you do, update the file that owns it (repo-wide → this file or [STYLE_GUIDE.md](./STYLE_GUIDE.md); scoped to one package → that package's nested `AGENTS.md`; a workflow an agent follows → the relevant skill), and delete guidance a change makes obsolete in the same pass — stale guidance is worse than none. Reviewers check for drift as a backstop (see [.github/claude-review-prompt.md](.github/claude-review-prompt.md)).\n- Keep them **as concise as possible**: only the minimal directives an expert needs. No tutorials, no restating what the code already says.\n\n## Troubleshooting\n\n- **Integration tests run stale service code.** `@atproto/dev-env` loads services through their package exports from `dist`. After changing service code, run `pnpm run build` inside that service package before rerunning its integration tests.\n- **Stale codegen.** If the build fails due to a generated file in [packages/api](packages/api) being out of date, run `pnpm run codegen && pnpm run build` from that package, then re-run the build. This is only needed there because its `prebuild` step skips codegen as a performance optimization.\n- **Codegen ran but produced stale output.** Codegen relies on `pnpm build:tooling` to build the `@atproto/lex-cli` and `@atproto/lex-builder` packages first. If you see a codegen failure, run `pnpm build:tooling` from the root, then re-run codegen.\n- **End-to-end test fails with stale infra.** If docker containers persist across test runs, reset them with `cd packages/dev-infra && docker compose down --volumes`.\n- **Nothing else worked.** `make clean` wipes every installed dependency (`node_modules`), build artifact (`dist`, `*.tsbuildinfo`), and prebuild/codegen output across all packages; follow it with `pnpm install && pnpm run build` to restore a clean state.\n","CLAUDE.md":"# CLAUDE.md\n\n@AGENTS.md\n","packages/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2FREADME.md -->","packages/api/README.md":"# ATP API\n\nThis API is a client for ATProtocol servers. It communicates using HTTP. It includes:\n\n- ✔️ APIs for ATProto and Bluesky.\n- ✔️ Validation and complete typescript types.\n- ✔️ Session management.\n- ✔️ A RichText library.\n\n> [!Note]\n>\n> We recommend using [lex](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), our type-safe SDK with Lexicon code generation, for all new projects.\n\n## Getting started\n\nFirst install the package:\n\n```sh\nyarn add @atproto/api\n```\n\nThen in your application:\n\n```typescript\nimport { Agent, CredentialSession } from '@atproto/api'\n\nconst session = new CredentialSession(new URL('https://bsky.social'))\nawait session.login(account)\nconst agent = new Agent(session)\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 `Agent`\nclass.\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 { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api'\n\n// Configure connection to the server with authentification\nconst account: AtpAgentLoginOpts = {\n  identifier: 'your.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n}\n\nasync function authenticate(account: AtpAgentLoginOpts): Promise<Agent> {\n  const session = new CredentialSession(new URL('https://example.com'))\n  await session.login(account)\n  const agent = new Agent(session)\n  return agent\n}\n\n;(async () => {\n  console.log('Authenticating...')\n  const agent = await authenticate(account)\n  console.log(`Authenticated as from: ${agent.sessionManager.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\n`OAuthSession` instance that can be used to instantiate an `Agent` (from\n`@atproto/api`). Here is an example restoring a previously saved session:\n\n```typescript\nimport { Agent } from '@atproto/api'\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 api Agent using an OAuthSession\nconst agent = new Agent(oauthSession)\n```\n\n### API calls\n\nThe agent includes methods for many common operations, including:\n\n```typescript\n// The DID of the user currently authenticated (or undefined)\nagent.did\nagent.accountDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait agent.getTimeline(params, opts)\nawait agent.getAuthorFeed(params, opts)\nawait agent.getPostThread(params, opts)\nawait agent.getPost(params)\nawait agent.getPosts(params, opts)\nawait agent.getLikes(params, opts)\nawait agent.getRepostedBy(params, opts)\nawait agent.post(record)\nawait agent.deletePost(postUri)\nawait agent.like(uri, cid)\nawait agent.deleteLike(likeUri)\nawait agent.repost(uri, cid)\nawait agent.deleteRepost(repostUri)\nawait agent.uploadBlob(data, opts)\n\n// Social graph\nawait agent.getFollows(params, opts)\nawait agent.getFollowers(params, opts)\nawait agent.follow(did)\nawait agent.deleteFollow(followUri)\n\n// Actors\nawait agent.getProfile(params, opts)\nawait agent.upsertProfile(updateFn)\nawait agent.getProfiles(params, opts)\nawait agent.getSuggestions(params, opts)\nawait agent.searchActors(params, opts)\nawait agent.searchActorsTypeahead(params, opts)\nawait agent.mute(did)\nawait agent.unmute(did)\nawait agent.muteModList(listUri)\nawait agent.unmuteModList(listUri)\nawait agent.blockModList(listUri)\nawait agent.unblockModList(listUri)\n\n// Notifications\nawait agent.listNotifications(params, opts)\nawait agent.countUnreadNotifications(params, opts)\nawait agent.updateSeenNotifications()\n\n// Identity\nawait agent.resolveHandle(params, opts)\nawait agent.updateHandle(params, opts)\n\n// Legacy: Session management should be performed through the SessionManager\n// rather than the Agent instance.\nif (agent instanceof AtpAgent) {\n  // AtpAgent instances support using different sessions during their lifetime\n  await agent.createAccount({ ... }) // session a\n  await agent.login({ ... }) // session b\n  await agent.resumeSession(savedSession) // session c\n}\n```\n\n### Validation and types\n\nThe package includes a complete types system which includes validation and type-guards. For example, to validate a post record:\n\n```typescript\nimport { AppBskyFeedPost } from '@atproto/api'\n\nconst post = {...}\nif (AppBskyFeedPost.isRecord(post)) {\n  // typescript now recognizes `post` as a AppBskyFeedPost.Record\n  // however -- we still need to validate it\n  const res = AppBskyFeedPost.validateRecord(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 { RichText } from '@atproto/api'\n\n// creating richtext\nconst rt = new RichText({\n  text: 'Hello @alice.com, check out this link: https://example.com',\n})\nawait rt.detectFacets(agent) // automatically detects mentions and links\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: new Date().toISOString(),\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 { moderatePost } from '@atproto/api'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: agent.session.did,\n  moderationPrefs: 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 include in feeds\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 methods above are convenience wrappers. It covers most but not all available methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can use them on the agent as well:\n\n```typescript\nconst res1 = await agent.com.atproto.repo.createRecord({\n  did: alice.did,\n  collection: 'app.bsky.feed.post',\n  record: {\n    $type: 'app.bsky.feed.post',\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n})\nconst res2 = await agent.com.atproto.repo.listRecords({\n  repo: alice.did,\n  collection: 'app.bsky.feed.post',\n})\n\nconst res3 = await agent.app.bsky.feed.post.create(\n  { repo: alice.did },\n  {\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n)\nconst res4 = await agent.app.bsky.feed.post.list({ repo: alice.did })\n```\n\n### Non-browser configuration\n\nIf your environment doesn't have a built-in `fetch` implementation, you'll need\nto provide one. This will typically be done through a polyfill.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation, you can do so by\ninstantiating the sessionManager with a custom fetch implementation:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst myFetch = (input: RequestInfo | URL, init?: RequestInit) => {\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 agent = new AtpAgent({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\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/api/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  moderationPrefs: {\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: string\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: AppBskyActorDefs.MutedWord[]\n  hiddenPosts: string[]\n}\n\nexport interface ModerationOpts {\n  userDid: string | 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 `agent.getPreferences()` method:\n\n```typescript\nconst prefs = await agent.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 this static function, though the default is to use Bluesky's moderation:\n\n```typescript\nBskyAgent.configure({\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\nUsers may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed.\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 definition 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.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours).\n\nHere is how to do this:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst agent = new AtpAgent({ service: 'https://example.com' })\n// assume `agent` is a signed in session\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\nmoderatePost(post, {\n  userDid: agent.session.did,\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  moderateFeedGen,\n  moderateUserList,\n  moderateLabeler,\n} from '@atproto/api'\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## 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 `Atproto-Proxy` header:\n\n```typescript\nagent\n  .withProxy('atproto_labeler', 'did:web:my-labeler.com')\n  .createModerationReport({\n    reasonType: 'com.atproto.moderation.defs#reasonViolation',\n    reason: 'They were being such a jerk to me!',\n    subject: { did: 'did:web:bob.com' },\n  })\n```\n","packages/aws/README.md":"# AWS KMS\n\nA Keypair-compatible wrapper for AWS KMS.\n","packages/bsky/AGENTS.md":"# AppView hydration\n\n- Endpoint-specific viewer block checks must honor `HydrateCtx.skipViewerBlocks` for the trusted moderation service, while preserving viewer block metadata and mute filtering.\n- In `hydration/external.ts`, keep shared types above `ExternalHydrator`; place helpers and their specific types below the class.\n- `ExternalHydrator` generic record lookups decode record bodies without collection-specific validation. Validate against the relevant Lexicon before using typed fields; do not apply parse-mode defaults to stored records.\n- `getRecordsByURI`/`getRecordsByRef` split input into sequential requests of at most `MAX_RECORDS_PER_REQUEST` (the dataplane rejects more than 100 URIs), pairing results positionally per request. Any failed request rejects the whole call; never return partially fetched batches. Bound requests there, not in callers or traversal.\n- `Views.externalRecordView` consumes `HydrationState.externalRecords` (latest versions), `externalRecordBacklinks` (target URI → available source URI samples), and `externalRecordBacklinkCounts`. Hydrate referenced publications, gallery item/photo records, backlink source records, labels, and profiles before building views. Counts are independent of the sampled previews.\n- External view builders keep strict Lexicon validation (`$matches`). Handle third-party records that violate their own published lexicon with a minimal compatibility override (see `lexicons-overrides/README.md`), not by loosening view validation.\n- Streamplace uses a declared HTTP(S) URL or falls back to its AT URI. A stream is active only without `endedAt` and with a `lastSeenAt` within `LIVESTREAM_HEARTBEAT_WINDOW_MS` (2 minutes) of `now`, on either side; record `idleTimeoutSeconds` and `createdAt` are ignored.\n- Atmosphere Explore uses each worker item’s `canonicalUri` as its sole hydration/view root; a missing or invalid root skips the item with no fallback or backfill. With the `AtmosphereExploreArticlesIrisEnable` gate off, worker groups also supply articles; with it on, up to 10 Iris `site.standard.document` roots (fetched concurrently with the worker) are the exclusive article source when the call succeeds with a nonempty articles list: worker articles are ignored and worker location/topic selection does not apply to them. Every list is capped at 10: selected record-backed lists (including Iris articles) are shuffled before hydration and presentation preserves that order; apps are capped at 10 in worker order. Livestream editorial windows constrain visibility before selection and capping without replacing heartbeat-based liveness. App-profile hydration is batched for the selected apps under the same request context, and upstream-only metadata stays out of public cards. Worker ingestion is best-effort per item: malformed banners, groups, and app cards are skipped without failing siblings or the request; only the first 100 entries per worker section are examined. Call Iris directly with request-scoped headers and abort signals, explicitly enabling XRPC response validation even outside debug mode; do not add a separate response parser. Keep article-root validation and the 10-item cap. A successful empty Iris list falls back to worker-curated articles and retains Iris's rec ID; a nonempty Iris list fully removed by root filtering or hydration does not trigger curated fallback. Catch Iris request and response-validation failures (rethrowing request aborts): log a warning and fall back to worker-curated articles with no rec ID; worker failures stay fatal, and the gate-on missing-Iris-client 501 is unchanged. The worker fetch retains its decoded-byte limit and deadline. Each list is an `{ items, recIdStr? }` section; `articles.recIdStr` comes from a successful Iris response, including the empty-list worker fallback, rather than identifying the source of the returned articles. Keep root validation in `getAtmosphereExploreTab.ts`; one shared hydration traversal discovers publications and other dependencies.\n- Exact-ref hydration maps are keyed by the branded `ExactRecordKey` from `genericRecordKey({ uri, cid })`, not URI alone; `parseGenericRecordKey` accepts only those keys and throws on malformed ones. Unavailable records are `null`, and takedowns are excluded unless explicitly requested.\n- Preserve `truncated` from actor backlink lookups: an empty target entry in a truncated batch does not prove the actor has no links.\n- Saved-post hydration selects one CID-pinned root per embed, regardless of collection: the first authored ref, except a legacy publication-first pair may select a document at index 1. Never inspect later refs or fall back from an unavailable selected root. Batch selected roots across post layers through the generic exact-ref traversal. A document's publication is a discovered latest dependency, not a second authored pin; discarded refs must not affect rendering or moderation. A publication selected as the root remains exact.\n- Pass all external URIs to `Hydrator.hydrateExternalViewDependencies`; dispatch by URI collection and validate records inside it. Batch new dependencies through `hydrateEmbedExternalViewFromUris`, sharing a `seenRecordKeys` set (including null lookups) across nested passes. Follow only complete DID-based record URIs.\n- `hydrateEmbedExternalViewFromUris` nulls each generic record (in `externalRecords` and `externalRecordsByRef`, keeping keys) that has an actionable takedown label (unless `includeTakedowns`) or an explicitly `null` owner actor, before dependency expansion. Hiding is per record: never propagate it to targets or dependencies, and leave backlink counts and samples unadjusted.\n- Dependency expansion is bounded by collection-typed edges and per-source sample limits, not a page-wide allowance. Validate direct references and backlink results with `recordUriOf`: complete DID record URI, expected collection, no query/fragment. A source query must never admit a URI of another collection, which could turn a terminal preview into another expanding root. Keep dependencies batched/deduped across posts; direct-only passes issue no backlink or count calls.\n- External hydration traversals share a `seenRecordKeys` set that both wrappers filter against at entry by `genericRecordKey(ref)` (bare URI for latest, `uri@cid` for exact), so each lookup runs once per traversal (null results stay seen). `MAX_EXTERNAL_HYDRATION_PASSES` hard-caps chained batches (root is pass 1; `hydrationPass` is threaded through both methods and never reset). Physical request chunks within a batch consume no passes. At the cap, keep partial state, leave skipped URIs unseen, and warn.\n- External-view counts, backlink pages, and nested dependency batches are best-effort: log failures and leave their state absent (a successful empty page is `[]`), and builders consume partial state, so galleries may be empty. Root fetch errors still propagate. Each recovered failure sets the tracker's `partial` flag, so the traversal reports `partial` unless it was `capped`, which takes precedence. Don't add failure-policy machinery.\n- Thread the optional `hydrationMetrics` tracker through every nested external hydration call (wrappers and `hydrateExternalViewDependencies`). Only the call without one owns the traversal and reports `events.externalHydrationTraversal` once; omitting it in a nested call double-reports. Count lookups at batch scheduling, never from map or seen-set sizes, and keep totals out of metric attributes. `batches` counts logical passes, not RPCs. The reported endpoint is `HydrateCtx.xrpcEndpoint`, an NSID-formatted string that handlers set from their method's `$lxm`. Discovery counts each inspected ref as resolved if available after moderation; the first discovery call's refs are roots (tracked by a flag, not pass number), later ones dependencies.\n- `hydrateEmbedExternalViewFromUris` and `hydrateEmbedExternalViewFromRefs` are thin wrappers over one pipeline and one collection dispatch. Latest lookups are identified by URI (`externalRecords`); exact versions by `genericRecordKey({ uri, cid })` (`externalRecordsByRef`). Both identities share `seenRecordKeys`, and one never implies the other; a latest fetch does not mark its returned version as an exact lookup. Dependency discovery takes `ItemRef[]` and inspects every pinned version; nested dependencies are authored URIs and always go through the URI wrapper. A successful lookup with the same URI and CID may replace an unavailable pinned version; a different CID never satisfies it.\n- External hydration takes `ExternalHydrationOptions` (`hydration`, plus the shared `seenRecordKeys`, `hydrationPass`, `hydrationMetrics`). `'basic'` keeps renderable content (including gallery item samples and photos, publication discovery), owner profiles/labels/moderation and backlink counts, but skips social-preview samples and their records, participant profiles and labels. `'detailed'` (default) adds those previews. Collection rules in `discoverExternalViewDependencies` decide this, never callsites; counts are scheduled for every target even with no sample queries. One level applies to a whole traversal: pass it through every nested call. `hydratePosts` (feeds) uses basic. The level never changes identity, pin or latest-dependency semantics.\n\n# External embed validation utilities\n\n- `util/external/standard-site.ts` (`getStandardSiteRecordsForUri`, `validateStandardSiteRecordsForUrl`) and `util/external/index.ts` (`isExternalRecordUrlSafe`) are pure and synchronous over caller-supplied hydrated records, URLs, and a domain list: no I/O, config reads, mutation, or view construction.\n\n# `app.bsky.embed.getEmbedExternalView`\n\n- The handler normalizes the request to one record URI before hydration: an explicit `canonicalUri` always wins (ignoring `uris`, with no fallback); otherwise the legacy `uris` input selects the first `site.standard.document`, else the first `site.standard.publication`. Only the one URI is hydrated; dependencies come from existing hydration.\n- Validate before building any view: Standard.site records via `getStandardSiteRecordsForUri` + `validateStandardSiteRecordsForUrl`, other records via `isExternalRecordUrlSafe(url, cfg.externalRecordsSafeDomains)`. Renderability is never the trust test.\n- Validated records return `data` and `associatedRefs`/`associatedRecords`; failed validation returns only `unverifiedAssociatedRefs`/`unverifiedAssociatedRecords` and builds no view. The legacy `view` is returned for Standard.site records only. Both outputs and the legacy `view.external.associatedRefs` use the same index-aligned persisted subset, canonical first.\n- Render-time hydration and persistence are separate. Hydration may include engagement records (recommendations, subscriptions, favorites) that back `likers`/`subscribers` previews; that is intentional. `collectAssociations` persists the canonical record when available (whatever its collection) plus only the extra collections opted in by `PERSISTED_DEPENDENCY_COLLECTIONS` (document → publication; gallery → gallery item and photo), to keep post records slim. It is not an engagement blacklist. A new persisted dependency collection needs an explicit entry; do not change hydration or views for this.\n","packages/bsky/README.md":"# @atproto/bsky: Bluesky AppView Service\n\nTypeScript implementation of the `app.bsky` Lexicons backing the https://bsky.app microblogging application.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsky)](https://www.npmjs.com/package/@atproto/bsky)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/bsky/src/feature-gates/README.md":"# Feature Gates\n\nA thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag\nevaluation and experiment tracking.\n\n## Usage\n\nFeature gates are accessible on `HydrationCtx` as `features`. A default \"scope\"\nis defined in `Hydrator.createContext`, which is called by every request\nhandler. The default scope supplies anonymous `deviceId` and `sessionId`\nidentifiers for targeting unauthenticated users.\n\nFeature gates can be checked via the following methods.\n\n```typescript\n// check a single gate\nconst enabled = ctx.features.checkGate(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n\n// check multiple gates\nconst gates = ctx.features.checkGates([\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n  ctx.features.Gate.SomeOtherGate,\n])\nconst enabled = gates.get(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n```\n\n### User Context\n\nTo accurately gate features by user, we need additional context about that user.\nFor most use cases, we get this data from within the request handlers.\n\nHere, the `features` client will be scoped to the current user, allowing for\naccurate gate checks throughout the request lifecycle.\n\n```typescript\nconst features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(\n  ctx.featureGatesClient.parseUserContextFromHandler({\n    viewer,\n    req,\n  }),\n)\n\nconst hydrateCtx = await ctx.hydrator.createContext({\n  labelers,\n  viewer,\n  features,\n})\n```\n\n> [!NOTE]\n> Although `ctx.featureGatesClient` also has a `checkGates` method, the\n> intention is to use the `ScopedFeatureGatesClient` returned by `scope()` for\n> gate checks within the application.\n\nYou can also pass the user context directly to `checkGate` or `checkGates`. This\noverrides any default scope set on the client, allowing for flexibility in cases\nwhere user context is only available at the point of gate evaluation.\n\n```typescript\nconst enabled = ctx.featureGatesClient.checkGate(\n  ctx.featureGatesClient.Gate.ImageFeatureEnabled,\n  { did: imageAuthor.did },\n)\n```\n\n### Adding Gates\n\nSee `gates.ts` and add them to the enum. You can optionally prevent metrics from\nbeing emitted for a gate by adding the gate to the `IGNORE_METRICS_FOR_GATES`\nset.\n\n## Metrics\n\nCheck out the Growthbook docs for more info here. Basically, every feature eval\nfires the `onFeatureUsage` callback. Any feature that is part of an experiment\n_will also fire the `trackingCallback` callback._\n\nFor some use cases, this can be noisy and/or performance intensive, so make use\nof `IGNORE_METRICS_FOR_GATES` to silence metrics for specific gates.\n","packages/bsync/README.md":"# @atproto/bsync: Synchronizing Service for the Bluesky AppView\n\nThis is an optional service that may be used to synchronize certain state between otherwise independent AppViews.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsync)](https://www.npmjs.com/package/@atproto/bsync)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/common-web/README.md":"# @atproto/common-web\n\nShared TypeScript code for other `@atproto/*` packages, which is web-friendly (runs in-browser).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common-web)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/common/README.md":"# @atproto/common\n\nShared TypeScript code for other `@atproto/*` packages. This package is oriented towards writing servers, and is not designed to be browser-compatible.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/crypto/README.md":"# @atproto/crypto\n\nTypeScript library providing basic cryptographic helpers as needed in [atproto](https://atproto.com).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/crypto)](https://www.npmjs.com/package/@atproto/crypto)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\nThis package implements the two currently supported cryptographic systems:\n\n- P-256 elliptic curve: aka \"NIST P-256\", aka secp256r1 (note the r), aka prime256v1\n- K-256 elliptic curve: aka \"NIST K-256\", aka secp256k1 (note the k)\n\nThe details of cryptography in atproto are described in [the specification](https://atproto.com/specs/cryptography). This includes string encodings, validity of \"low-S\" signatures, byte representation \"compression\", hashing, and more.\n\n## Usage\n\n```typescript\nimport { verifySignature, Secp256k1Keypair, P256Keypair } from '@atproto/crypto'\n\n// generate a new random K-256 private key\nconst keypair = await Secp256k1Keypair.create({ exportable: true })\n\n// sign binary data, resulting signature bytes.\n// SHA-256 hash of data is what actually gets signed.\n// signature output is often base64-encoded.\nconst data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])\nconst sig = await keypair.sign(data)\n\n// serialize the public key as a did:key string, which includes key type metadata\nconst pubDidKey = keypair.did()\nconsole.log(pubDidKey)\n\n// output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38'\n\n// verify signature using public key\nconst ok = verifySignature(pubDidKey, data, sig)\nif (!ok) {\n  throw new Error('Uh oh, something is fishy')\n} else {\n  console.log('Success')\n}\n```\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/dev-env/README.md":"# @atproto/dev-env: Local Developer Environment\n\nA command-line application for developers to construct and manage development environments.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/dev-env)](https://www.npmjs.com/package/@atproto/dev-env)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\n## REPL API\n\nThe following methods are available in the REPL.\n\n### `status()`\n\nList the currently active servers.\n\n### `startPds(port?: number)`\n\nCreate a new PDS instance. Data is stored in memory.\n\n### `stop(port: number)`\n\nStop the server at the given port.\n\n### `mkuser(handle: string, pdsPort?: number)`\n\nCreate a new user.\n\n### `user(handle: string): ServiceClient`\n\nGet the `ServiceClient` for the given user.\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/dev-infra/README.md":"# dev-infra\n\nHelpers for working with postgres and redis locally. Previously known as `pg`.\n\n## Usage\n\n### `with-test-db.sh`\n\nThis script allows you to run any command with a fresh, ephemeral/single-use postgres database available. When the script starts a Dockerized postgres container starts-up, and when the script completes that container is removed.\n\nThe environment variable `DB_POSTGRES_URL` will be set with a connection string that can be used to connect to the database. The [`PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) that are recognized by libpq (i.e. used by the `psql` client) are also set.\n\n**Example**\n\n```\n$ ./with-test-db.sh psql -c 'select 1;'\n[+] Running 1/1\n ⠿ Container pg-db_test-1  Healthy                                                           1.8s\n\n ?column?\n----------\n        1\n(1 row)\n\n\n[+] Running 1/1\n ⠿ Container pg-db_test-1  Stopped                                                           0.1s\nGoing to remove pg-db_test-1\n[+] Running 1/0\n ⠿ Container pg-db_test-1  Removed\n```\n\n### `with-redis-and-test-db.sh`\n\nThis script is similar to `with-test-db.sh`, but in addition to an ephemeral/single-use postgres database it also provides a single-use redis instance. When the script starts, Dockerized postgres and redis containers start-up, and when the script completes the containers are removed.\n\nThe environment variables `DB_POSTGRES_URL` and `REDIS_HOST` will be set with a connection strings that can be used to connect to postgres and redis respectively.\n\n### `docker-compose.yaml`\n\nThe Docker compose file can be used to run containerized versions of postgres either for single use (as is used by `with-test-db.sh`), or for longer-term use. These are setup as separate services named `db_test` and `db` respectively. In both cases the database is available on the host machine's `localhost` and credentials are:\n\n- Username: pg\n- Password: password\n\nHowever, each service uses a different port, documented below, to avoid conflicts.\n\n#### `db_test` service for single use\n\nThe single-use `db_test` service does not have any persistent storage. When the container is removed, data in the database disappears with it.\n\nThis service runs on port `5433`.\n\n```\n$ docker compose up db_test   # start container\n$ docker compose stop db_test # stop container\n$ docker compose rm db_test   # remove container\n```\n\n#### `db` service for persistent use\n\nThe `db` service has persistent storage on the host machine managed by Docker under a volume named `pg_atp_db`. When the container is removed, data in the database will remain on the host machine. In order to start fresh, you would need to remove the volume.\n\nThis service runs on port `5432`.\n\n```\n$ docker compose up db -d    # start container\n$ docker compose stop db     # stop container\n$ docker compose rm db       # remove container\n$ docker volume rm pg_atp_db # remove volume\n```\n\n#### `redis_test` service for single use\n\nThe single-use `redis_test` service does not have any persistent storage. When the container is removed, the data in redis disappears with it.\n\nThis service runs on port `6380`.\n\n#### `redis` service for persistent use\n\nThe `redis` service has persistent storage on the host machine managed by Docker under a volume named `atp_redis`. When the container is removed, the data in redis will remain on the host machine. In order to start fresh, you would need to remove the volume.\n\nThis service runs on port `6379`.\n","packages/identity/README.md":"# @atproto/identity\n\nTypeScript library for decentralized identities in [atproto](https://atproto.com) using DIDs and handles\n\n[![NPM](https://img.shields.io/npm/v/@atproto/identity)](https://www.npmjs.com/package/@atproto/identity)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\n## Example\n\nResolving a Handle and verifying against DID document:\n\n```typescript\nconst didres = new DidResolver({})\nconst hdlres = new HandleResolver({})\n\nconst handle = 'atproto.com'\nconst did = await hdlres.resolve(handle)\n\nif (did == undefined) {\n  throw new Error('expected handle to resolve')\n}\nconsole.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz\n\nconst doc = await didres.resolve(did)\nconsole.log(doc)\n\n// additional resolutions of same DID will be cached for some time, unless forceRefresh flag is used\nconst doc2 = await didres.resolve(did, true)\n\n// helper methods use the same cache\nconst data = await didres.resolveAtprotoData(did)\n\nif (data.handle != handle) {\n  throw new Error('invalid handle (did not match DID document)')\n}\n```\n\n## SSRF protection\n\nHTTP identity resolution — the handle `/.well-known/atproto-did` endpoint,\n`did:plc`, and `did:web` — is routed through an SSRF-protected fetch by\ndefault. Private IPs, IP-literal hosts, `http:` URLs, custom ports, and a\nhandful of forbidden domain names are refused.\n\nTo resolve identities against localhost or another private network — a test\nsuite or a local dev stack — pass your own `fetch`. A supplied `fetch` is\nused as-is and is never re-wrapped:\n\n```typescript\nconst didres = new IdResolver({ plcUrl, fetch: globalThis.fetch })\n```\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/internal/fetch-node/README.md":"# Node.js fetch utilities\n\nFetch helpers for Node.js 22+ that help protect services when fetching URLs supplied by users.\n\n## Usage\n\nCreate a fetch wrapper once and reuse it:\n\n```js\nimport { safeFetchWrap } from '@atproto-labs/fetch-node'\n\nconst safeFetch = safeFetchWrap()\nconst response = await safeFetch('https://api.github.com/repos/nodejs/node', {\n  redirect: 'follow',\n})\nconst data = await response.json()\n```\n\nBy default, `safeFetchWrap` allows HTTPS on port 443, blocks private and reserved IP addresses and a list of forbidden domains, sets a 10-second timeout, and limits response bodies to 512 KiB. Destination checks also apply to redirects, and IP addresses are checked when connecting.\n\nPass `redirect` explicitly on each call: use `'follow'`, `'manual'`, or `'error'`. Configure the wrapper with options such as `timeout` (milliseconds), `responseMaxSize` (bytes), and `forbiddenDomainNames`. If you supply a custom `fetch`, it must honor Undici's `dispatcher` option; do not pass a separate dispatcher on individual calls.\n\n## Other helpers\n\n- `unicastFetchWrap()` restricts destinations to public IP addresses, including redirects, without adding the HTTPS, timeout, or response-size rules.\n- `safeDispatchFetchWrap({ checkUrl, lookup })` lets you supply your own destination checks. `checkUrl` receives the origin of each request, including redirects, and returns a reason string to block it or `undefined` to allow it.\n- `unicastLookup` checks DNS results before connecting. `isUnicastIpHostname(hostname)` checks a literal IP address and returns `undefined` for a domain name.\n- This package also exports the helpers from `@atproto-labs/fetch` for working with requests, responses, errors, and fetch wrappers.\n\n## Temporary eMudhra TLS mitigation\n\n`emudhra-preload` blocks HTTPS connections that use any of the eight intermediate keys affected by the [eMudhra compromise](https://emudhra.we-have-a-problem.com/), as listed in [nodejs/node#66871](https://github.com/nodejs/node/pull/66871). It keeps the existing trusted roots, so other intermediates under those roots still work. Remove it once you deploy Node.js with the fix.\n\nNode supports multiple `--import` flags. Load this before tracing and application code:\n\n```sh\nnode --import=@atproto-labs/fetch-node/emudhra-preload --import=@atproto/pds/telemetry service.js\n```\n\nOr copy [`src/emudhra-preload.mjs`](src/emudhra-preload.mjs) into your image and load it directly. It needs no other files or packages:\n\n```sh\nnode --import=/opt/mitigations/emudhra-preload.mjs --import=./tracing.mjs service.js\n```\n\nYou can also add `--import=/opt/mitigations/emudhra-preload.mjs` to `NODE_OPTIONS`. Restart every process and discard any TLS sessions saved before the preload was installed. Resumed sessions skip the check.\n\nThe preload works with Node's HTTPS client, built-in `fetch()`, and Undici's standard connectors, including custom CAs and hostname checks. It wraps `tls.connect()` and adds the key check to the client's existing certificate check. Blocked connections fail with `CERT_REVOKED` before any HTTP request data is sent.\n\nIt checks keys during full TLS handshakes and caches up to 256 certificate results to avoid repeated work. Normal certificate validation still runs. Reusing a connection or resuming a checked session adds no key-check work.\n\nLimits:\n\n- Keep certificate verification enabled: do not set `rejectUnauthorized: false` or `NODE_TLS_REJECT_UNAUTHORIZED=0`.\n- Clients must use `tls.connect()` after the preload runs. Other TLS libraries, direct `TLSSocket` use, and incoming client certificates are not covered. `--require` runs before `--import`, so avoid opening connections or saving a reference to `tls.connect` there.\n- Workers and forked processes normally inherit the preload. Keep it enabled if you override `execArgv` or `NODE_OPTIONS`.\n- A server can also be blocked if it sends an unused certificate with one of the eight keys. Unlike Node's native fix, this preload cannot check only the certificate path OpenSSL chose.\n","packages/internal/handle-resolver/README.md":"# Universal Handle Resolver implementation for ATPROTO\n\nThis package provides a handle resolver implementation for ATPROTO. It is used\nto resolve handles to their corresponding DID.\n\nThis package is meant to be used in any JavaScript environment that support the\n`fetch()` function. Because APTORO handle resolution requires DNS resolution,\nyou will need to provide your own DNS resolution function when using this\npackage.\n\nThere are two main classes in this package:\n\n- `AtprotoHandleResolver` This implements the official ATPROTO handle resolution\n  algorithm (and requires a DNS resolver).\n- `AppViewHandleResolver` This uses HTTP requests to the Bluesky AppView\n  (bsky.app) to provide handle resolution.\n\n## Usage\n\n### From a front-end app\n\nSince the ATPROTO handle resolution algorithm requires DNS resolution, and the\nbrowser does not provide a built-in DNS resolver, this package offers two\noptions:\n\n- Delegate handle resolution to an AppView (`AppViewHandleResolver`). This is\n  the recommended approach for front-end apps.\n- Use a DNS-over-HTTPS (DoH) server (`DohHandleResolver`). Prefer this method\n  if you don't own an AppView and already have a DoH server that you trust.\n\nUsing an AppView:\n\n> [!CAUTION]\n> Use the Bluesky owned AppView (`https://api.bsky.app/`), or PDS\n> (`https://bsky.social/`), at your own risk. Using these servers in a\n> third-party application might expose your users' data (IP address) to Bluesky.\n> Bluesky might log the data sent to it when your app is resolving handles.\n> Bluesky might also change the API, or terms or use, at any time without\n> notice. Make sure you are compliant with the Bluesky terms of use as well as\n> any laws and regulations that apply to your use case.\n\n```ts\nimport { AppViewHandleResolver } from '@atproto-labs/handle-resolver'\n\nconst resolver = new AppViewHandleResolver({\n  service: 'https://my-app-view.com/',\n})\nconst did = await resolver.resolve('my-handle.bsky.social')\n```\n\nUsing DNS-over-HTTPS (DoH) for DNS resolution:\n\n> [!CAUTION]\n> Using a DoH server that you don't own might expose your users' data to\n> the DoH server provider. The DoH server provider might log the data sent to it\n> by your app, allowing them to track which handles are being resolved by your\n> users. In the browser, it is recommended to use a DoH server that you own and\n> control. Or to implement your own AppView and use the `AppViewHandleResolver`\n> class.\n\n> [!NOTE]\n> Using the `DohHandleResolver` requires a DNS-over-HTTPS server that\n> supports the DNS-over-HTTPS protocol with \"application/dns-json\" responses.\n\n```ts\nimport { DohHandleResolver } from '@atproto-labs/handle-resolver'\n\n// Also works with 'https://cloudflare-dns.com/dns-query'\nconst resolver = new DohHandleResolver('https://dns.google/resolve', {\n  // Optional: Custom fetch function that will be used both for DNS resolution\n  // and well-known resolution.\n  fetch: globalThis.fetch.bind(globalThis),\n})\n\nconst did = await resolver.resolve('my-handle.bsky.social')\n```\n\n### From a Node.js app\n\n> [!NOTE]\n> On a Node.js backend, you will probably want to use the\n> \"@atproto-labs/handle-resolver-node\" package. The example below applies to\n> Node.js code running on a user's machine (e.g. through Electron).\n\n```ts\nimport { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'\nimport { resolveTxt } from 'node:dns/promises'\n\nconst resolver = new AtprotoHandleResolver({\n  // Optional: Custom fetch function (used for well-known resolution)\n  fetch: globalThis.fetch.bind(globalThis),\n\n  resolveTxt: async (domain: string) =>\n    resolveTxt(domain).then((chunks) => chunks.join('')),\n})\n```\n\n### Caching\n\nUsing a default, in-memory cache, in which items expire after 10 minutes:\n\n```ts\nimport {\n  AppViewHandleResolver,\n  CachedHandleResolver,\n  HandleResolver,\n  HandleCache,\n} from '@atproto-labs/handle-resolver'\n\n// See previous examples for creating a resolver\ndeclare const sourceResolver: HandleResolver\n\nconst resolver = new CachedHandleResolver(sourceResolver)\nconst did = await resolver.resolve('my-handle.bsky.social')\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\n```\n\nUsing a custom cache:\n\n```ts\nimport {\n  AppViewHandleResolver,\n  CachedHandleResolver,\n  HandleResolver,\n  HandleCache,\n} from '@atproto-labs/handle-resolver'\n\n// See previous examples for creating a resolver\ndeclare const sourceResolver: HandleResolver\n\nconst cache: HandleCache = {\n  set(handle, did): Promise<void> {\n    /* TODO */\n  },\n  get(handle): Promise<undefined | string> {\n    /* TODO */\n  },\n  del(handle): Promise<void> {\n    /* TODO */\n  },\n}\n\nconst resolver = new CachedHandleResolver(sourceResolver, cache)\nconst did = await resolver.resolve('my-handle.bsky.social')\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\n```\n\n### Observing resolution errors\n\nBy default, `WellKnownHandleResolver` (used internally by `AtprotoHandleResolver`\nand `AtprotoHandleResolverNode`) returns `null` for non-abort errors,\nindistinguishable from \"no `.well-known/atproto-did` endpoint exists\". That is\ncorrect for the resolver contract, but it hides operational signals such as 5xx\nupstream errors, SSRF blocks, redirect loops, or transient network failures.\n\nTo log or surface those failures, pass an `onError` handler when constructing the\nresolver:\n\n```ts\nconst resolver = new AtprotoHandleResolverNode({\n  onError: (err, { resolver, handle }) => {\n    console.warn(\n      `[handle-resolver:${resolver}] ${handle} failed to resolve:`,\n      err,\n    )\n  },\n})\n```\n\nBecause the handler is set on the instance, it also fires for resolution done on\nyour behalf, which a per-call option cannot reach. For example, an OAuth client\nyou hand the resolver to:\n\n```ts\nconst client = new NodeOAuthClient({\n  handleResolver: new AtprotoHandleResolverNode({ onError: logHandleError }),\n  // ...\n})\n```\n\nThe handler is observational only: the resolver still returns `null` on failure,\nso existing behavior does not change. Use it to:\n\n- Log resolution failures for debugging or alerting.\n- Surface specific failures to end users (e.g. distinguishing a misconfigured\n  custom domain from a typo'd handle).\n- Emit telemetry for handle-host operators.\n","packages/internal/opentelemetry-node/README.md":"# @atproto-labs/opentelemetry-node\n\nOpenTelemetry bootstrap for atproto Node.js services.\n\nThis package wires up the OpenTelemetry Node SDK from the conventional `OTEL_*`\nenvironment variables, so a service only has to supply its identity and the list\nof instrumentations it wants.\n\nTelemetry is **opt-in via the exporter endpoint**: the SDK stays disabled until an\nOTLP endpoint is configured (`OTEL_EXPORTER_OTLP_ENDPOINT` or a signal-specific\nvariant). `OTEL_SDK_DISABLED` remains a kill switch.\n\n## Configuration\n\nEverything beyond the service identity and instrumentation list is driven by the\nconventional `OTEL_*` environment variables. See the OpenTelemetry docs for the\nfull set:\n\n- [General SDK configuration](https://opentelemetry.io/docs/languages/sdk-configuration/general/)\n- [OTLP exporter configuration](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/)\n\n## Usage\n\nLoad it before any instrumented module — typically through Node's `--import` flag:\n\n```ts\nimport { setup } from '@atproto-labs/opentelemetry-node'\nimport { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from '@atproto-labs/opentelemetry-node/conventions'\nimport { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'\nimport pkg from './package.json' with { type: 'json' }\n\nawait setup(() => ({\n  name: pkg.name,\n  version: pkg.version,\n  // Optional: override or extend the default resource attributes.\n  defaultResourceAttributes: { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: 'staging' },\n  // Service-specific instrumentations. The instrumentations common to atproto\n  // services are always registered in addition to these.\n  instrumentations: [new IORedisInstrumentation()],\n}))\n```\n\n`setup()` takes a thunk so nothing is constructed when telemetry is disabled: the\noptions — and therefore the instrumentations — are only built once an OTLP endpoint\nis configured.\n\n### XRPC routes on metrics\n\nPass the known XRPC methods the service may answer (including the ones it\nproxies) as `xrpcMethods`. This allows to set the `http.route` attribute\ncorrectly for these methods in the metrics. When `xrpcMethods` is omitted, the\n`http.route` of the metrics is left as set by the Express instrumentation.\n\n```ts\n// @NOTE **do not** import runtime code here (like `ids` from\n// `@atproto/lex-cli`'s `./lexicon/lexicons.js`) as telemetry setup must run\n// before any instrumented module. Importing from the `@atproto/lex` manifest\n// (JSON) is fine.\n\nimport { setup } from '@atproto-labs/opentelemetry-node'\nimport pkg from './package.json' with { type: 'json' }\nimport lex from './lexicons.json' with { type: 'json' }\n\nawait setup(() => ({\n  name: pkg.name,\n  version: pkg.version,\n  xrpcMethods: lex.lexicons\n    // @NOTE we only care about methods, which, by convention, have a verb\n    // followed by a capitalized noun (e.g., `getFeed`). Reducing the list\n    // helps keep the attribute low-cardinality.\n    .filter((v) => /[A-Z]/.test(v)),\n}))\n```\n\nThis sets the `http.route` attribute of the HTTP server duration metric to\n`/xrpc/<nsid>` for these methods, and to `/xrpc/{unknown}` for any other NSID.\nThe list is what keeps the attribute low-cardinality: any client\ncan make up an NSID. Span names aren't affected.\n\nInstrumentations you supply must be built on the same\n`@opentelemetry/instrumentation` as this package, or ESM imports of the modules\nthey target can't be instrumented: `setup()` then rejects with an\n`InstrumentationMismatchError`, failing the service start. For `better-sqlite3`, use the\n`BetterSqlite3Instrumentation` exposed by the\n`@atproto-labs/opentelemetry-node/better-sqlite3-plugin` entrypoint: a port of\n[`opentelemetry-plugin-better-sqlite3`](https://github.com/sjinks/opentelemetry-plugin-better-sqlite3)\nthat satisfies this requirement.\n\nThe `@atproto-labs/opentelemetry-node/conventions` entrypoint re-exports\n`@opentelemetry/semantic-conventions` plus the atproto-specific constants: the\nXRPC attribute keys (`ATTR_XRPC_METHOD`, `ATTR_XRPC_PROXIED`,\n`ATTR_XRPC_PROXY`), the bsync stash attribute keys (`ATTR_BSYNC_NAMESPACE`,\n`ATTR_BSYNC_OPERATION`), and `RPC_CALL_DURATION_BUCKETS`. The\n`@atproto-labs/opentelemetry-node/util` entrypoint exposes\n`statusCodeToString()`, which renders a Connect status as the snake_case string\n`rpc.response.status_code` expects.\n\nBoth sides of an RPC need to agree on these, so they live here rather than in\neither service — otherwise the caller and the callee end up depending on each\nother just to label a metric.\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\nBluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).\n","packages/internal/rolldown-plugin-bundle-manifest/README.md":"# @atproto-labs/rolldown-plugin-bundle-manifest\n\nThis Rolldown plugin allows to generate a (JSON) manifest containing the output\nfiles of a Rolldown build. The manifest will look as follows:\n\n```json\n{\n  \"main.js\": {\n    \"type\": \"chunk\",\n    \"mime\": \"application/javascript\",\n    \"dynamicImports\": [],\n    \"isDynamicEntry\": false,\n    \"isEntry\": true,\n    \"name\": \"main\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  },\n  \"main.js.map\": {\n    \"type\": \"asset\",\n    \"mime\": \"application/json\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  },\n  \"main.css\": {\n    \"type\": \"asset\",\n    \"mime\": \"text/css\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  }\n  // ... more entries as needed\n}\n```\n\nThis manifest will typically be useful for a backend service that serves the\nfrontend assets, as it can be used to determine the correct `Content-Type` and\nand file integrity (via the SHA-256 hash), without having to read the files\nthemselves.\n\n## Usage\n\n```js\n// rolldown.config.js\n\nimport { bundleManifest } from '@atproto-labs/rolldown-plugin-bundle-manifest'\n\nexport default {\n  input: 'src/index.js',\n  output: {\n    dir: 'dist',\n    format: 'es',\n  },\n  plugins: [\n    bundleManifest({\n      name: 'bundle-manifest.json',\n\n      // Optional: should the asset data be embedded (as base64 string) in the manifest?\n      data: false,\n    }),\n  ],\n}\n```\n\n## Options\n\n- `name` (string): The name of the manifest file. Defaults to `bundle-manifest.json`.\n- `data` (boolean): Whether to embed the asset data in the manifest. Defaults to `false`.\n\n## Example\n\n```js\nconst assetManifest = require('./dist/bundle-manifest.json')\n\nconst app = express()\n\napp.use((req, res, next) => {\n  const asset = assetManifest[req.path.slice(1)]\n  if (!asset) return next()\n\n  res.setHeader('Content-Type', asset.mime)\n  res.setHeader('Content-Length', asset.data.length)\n\n  res.end(Buffer.from(asset.data, 'base64'))\n})\n\napp.use((req, res, next) => {\n  res.setHeader(\n    'Content-Security-Policy',\n    buildCSP(assetManifest), // Not provided here\n  )\n\n  // Serve the index.html file\n  res.sendFile('index.html')\n})\n```\n\n## License\n\nMIT\n","packages/lex-cli/README.md":"# Lexicon CLI Tool\n\nCommand-line tool to generate Lexicon schemas and APIs.\n\n## Usage\n\n```\nUsage: lex [options] [command]\n\nLexicon CLI\n\nOptions:\n  -V, --version                     output the version number\n  -h, --help                        display help for command\n\nCommands:\n  new [options] <nsid> [outfile]    Create a new schema json file\n  gen-md <schemas...>               Generate markdown documentation\n  gen-ts-obj <schemas...>           Generate a TS file that exports an array of schemas\n  gen-api <outdir> <schemas...>     Generate a TS client API\n  gen-server <outdir> <schemas...>  Generate a TS server API\n  help [command]                    display help for command\n```\n\n**Example 1:** Generate an api\n\n```\n$ lex gen-api ./api/src ./schemas/com/service/*.json ./schemas/com/another/*.json\n```\n\n**Example 2:** Generate a server\n\n```\n$ lex gen-server ./server/src/xrpc ./schemas/com/service/*.json ./schemas/com/another/*.json\n```\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/lex/lex-builder/README.md":"# @atproto/lex-builder\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nGenerates TypeScript schemas from [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex build` command.\n\n```typescript\nimport { build } from '@atproto/lex-builder'\n\nawait build({ lexicons: './lexicons', out: './src/lexicons' })\n```\n\nMost projects should use the `lex build` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.\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/lex/lex-cbor/README.md":"# @atproto/lex-cbor\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nCBOR ([DRISL](https://dasl.ing/drisl.html)) encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), as used for [repository](https://atproto.com/specs/repository) storage and signing.\n\n```typescript\nimport { decode, encode, cidForLex } from '@atproto/lex-cbor'\n\nconst bytes = encode(someLexValue)\nconst value = decode(bytes)\nconst cid = await cidForLex(someLexValue)\n```\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family. Unlike the JSON codec, this package is _not_ re-exported by `@atproto/lex` and must be installed directly.\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/lex/lex-client/README.md":"# @atproto/lex-client\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n- [`@atproto/lex-rpc` low-level XRPC utilities](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex-rpc/README.md)\n\nType-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) client for Lexicon-defined APIs: the `Client` class for working with a user's PDS (records) in the context of an authenticated session.\n\nAuthentication is supplied through an `Agent`, implemented by [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) or, for scripts and bots, [`@atproto/lex-password-session`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-password-session).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it -- prefer depending on that package.\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/lex/lex-data/README.md":"# @atproto/lex-data\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nCore types and utilities for the AT Protocol [data model](https://atproto.com/specs/data-model): `LexValue` and friends, CIDs, bytes, [blob](https://atproto.com/specs/blob) references, and string-length helpers.\n\nThe data model extends JSON with two additional structures — CIDs (content-addressed links) and raw bytes — which are encoded as JSON by [`@atproto/lex-json`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-json) and as CBOR by [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.\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/lex/lex-document/README.md":"# @atproto/lex-document\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nValidation of Lexicon documents themselves — the JSON files written in the [Lexicon schema definition language](https://atproto.com/specs/lexicon).\n\nWhere [`@atproto/lex-schema`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-schema) validates _data_ against a schema, this package validates the _schema documents_, and turns them into runtime schemas by resolving refs across documents.\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`lex-builder`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-builder), [`lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) and [`lex-resolver`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-resolver).\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/lex/lex-installer/README.md":"# @atproto/lex-installer\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nPackage manager for [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex install` command: it resolves Lexicons by [NSID](https://atproto.com/specs/nsid), writes them to a local directory, and tracks their versions (CIDs) in a `lexicons.json` manifest.\n\n```typescript\nimport { install } from '@atproto/lex-installer'\n\nawait install({\n  lexicons: './lexicons',\n  manifest: './lexicons.json',\n  add: ['app.bsky.feed.post'],\n  save: true,\n})\n```\n\nMost projects should use the `lex install` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.\n\n## Resolution sources\n\nEach Lexicon is resolved from, in order of priority:\n\n1. **An explicit `at://` URI** passed as an addition — fetched (and verified) from that repo.\n2. **An explicit local file** passed as an addition — any value that is a `file://` URI or\n   contains a slash (`./app.bsky.feed.post.json`, `file://../shared/my.json`) is read from disk\n   (relative to the cwd); its NSID is taken from the document's `id`.\n3. **The manifest's `resolvers`** — an ordered list of local override strategies consulted for\n   every dependency NSID before the network. Currently only `directory` is supported:\n\n   ```jsonc\n   {\n     \"version\": 1,\n     \"lexicons\": [\"com.example.foo\"],\n     \"resolvers\": [\n       {\n         \"type\": \"directory\",\n         \"path\": \"../../lexicons\", // relative to this lexicons.json\n         \"include\": [\"com.example.*\"], // optional NSID globs (default: all)\n         \"exclude\": [], // optional NSID globs this resolver never answers for\n       },\n     ],\n     \"resolutions\": {\n       \"com.example.foo\": {\n         \"uri\": \"file://../../lexicons/com/example/foo.json\",\n         \"cid\": \"...\",\n       },\n     },\n   }\n   ```\n\n   Resolvers are priority-ordered (first match wins) and are **not** reordered by manifest\n   normalization. A `directory` resolver maps an NSID to `<path>/<a>/<b>/<c>.json`.\n\n4. **The network** — the always-last fallback: NSID → DNS `_lexicon.<authority>` → DID → signed\n   `com.atproto.lexicon.schema` record.\n\n> [!NOTE]\n> A future `{ \"type\": \"repo\", ... }` resolver (resolving from specific DIDs rather than DNS\n> discovery) is planned but not yet implemented.\n\n### Symlinks\n\nLexicons resolved from a **local file** (sources 2 and 3 above) are **symlinked** into the output\n`lexicons/` directory rather than copied, so edits to the source file are reflected without\nreinstalling. The symlink is written relative to its destination for portability. As an exception,\nif the resolved destination path equals the source path, the file is left untouched. Their manifest\nlock `uri` is a `file://` path relative to the manifest, keeping it portable across checkouts.\nNetwork-resolved Lexicons are written as plain JSON files and locked with their `at://` URI.\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/lex/lex-json/README.md":"# @atproto/lex-json\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nJSON encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), including the `$link` (CID) and `$bytes` representations used by [XRPC](https://atproto.com/specs/xrpc).\n\nProvides `lexParse` / `lexStringify` to go between JSON strings and data-model values, and `jsonToLex` / `lexToJson` to convert already-parsed JSON objects. For the CBOR codec used by repository storage, see [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.\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/lex/lex-password-session/README.md":"# @atproto/lex-password-session\n\nPassword-based session authentication for AT Protocol Lexicons. See the [Changelog](./CHANGELOG.md) for version history.\n\n```bash\nnpm install @atproto/lex-password-session\n```\n\n- Session management with automatic token refresh\n- Hooks for persisting and monitoring session state\n- PDS endpoint discovery from DID documents\n- Two-factor authentication support\n\n> [!IMPORTANT]\n>\n> This package is currently in **preview**. The API and features are subject to change before the stable release.\n\n**What is this?**\n\n`@atproto/lex-password-session` provides a `PasswordSession` class that implements the `Agent` interface from `@atproto/lex-client`. It handles password-based authentication with AT Protocol services, including:\n\n1. Creating sessions with username/password credentials\n2. Automatic token refresh when access tokens expire\n3. Session persistence through lifecycle hooks\n4. Graceful logout with server-side session cleanup\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport * as app from './lexicons/app.js'\n\n// Login with credentials\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\n\nconst client = new Client(session)\n\n// Make authenticated requests\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: session.did,\n})\n```\n\n<!-- START doctoc generated TOC please keep comment here to allow auto update -->\n<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->\n\n- [Quick Start](#quick-start)\n- [PasswordSession](#passwordsession)\n  - [Login](#login)\n  - [Two-Factor Authentication](#two-factor-authentication)\n  - [Resume Session](#resume-session)\n  - [Logout](#logout)\n  - [Static Delete](#static-delete)\n  - [Create Account](#create-account)\n- [Session Hooks](#session-hooks)\n  - [onUpdated](#onupdated)\n  - [onUpdateFailure](#onupdatefailure)\n  - [onDeleted](#ondeleted)\n  - [onDeleteFailure](#ondeletefailure)\n- [Session Data](#session-data)\n- [Error Handling](#error-handling)\n- [Using with Client](#using-with-client)\n- [License](#license)\n\n<!-- END doctoc generated TOC please keep comment here to allow auto update -->\n\n## Quick Start\n\n**1. Install the package**\n\n```bash\nnpm install @atproto/lex-password-session @atproto/lex-client\n```\n\n**2. Login and make requests**\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your-handle.bsky.social',\n  password: 'your-app-password',\n})\n\nconst client = new Client(session)\n\n// Make authenticated API calls\nconsole.log('Logged in as:', session.did)\n```\n\n## PasswordSession\n\nThe `PasswordSession` class manages password-based authentication sessions.\n\n### Login\n\nCreate a new session with username and password:\n\n```typescript\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social', // handle or email\n  password: 'app-password',\n  onUpdated: (data) => {\n    // Persist session for later restoration\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: () => {\n    localStorage.removeItem('session')\n  },\n})\n\nconsole.log('Logged in as:', session.did)\n```\n\nThe `login()` method throws on failure. For expected errors like invalid credentials, an `XrpcResponseError` is thrown. For 2FA requirements, a `LexAuthFactorError` is thrown.\n\n### Two-Factor Authentication\n\n> [!CAUTION]\n>\n> Two-factor authentication only applies when using **main account credentials**, which is **strongly discouraged**. Password authentication should be used with [app passwords](https://bsky.app/settings/app-passwords) only because they are designed for programmatic access (bots, scripts, CLI tools). For user-facing applications, use OAuth via [@atproto/oauth-client](../../../oauth/oauth-client) which provides better security and user control.\n\nIf the account has 2FA enabled, login will throw a `LexAuthFactorError`:\n\n```typescript\nimport {\n  PasswordSession,\n  LexAuthFactorError,\n} from '@atproto/lex-password-session'\n\nasync function loginWith2FA(\n  identifier: string,\n  password: string,\n  authFactorToken?: string,\n): Promise<PasswordSession> {\n  try {\n    return await PasswordSession.login({\n      service: 'https://bsky.social',\n      identifier,\n      password,\n      authFactorToken,\n      onUpdated: (data) => saveToStorage(data),\n      onDeleted: (data) => removeFromStorage(data.did),\n    })\n  } catch (err) {\n    if (err instanceof LexAuthFactorError && !authFactorToken) {\n      // 2FA required - prompt user for code\n      const token = await promptUserFor2FACode(err.message)\n      return loginWith2FA(identifier, password, token)\n    }\n    throw err\n  }\n}\n```\n\n### Resume Session\n\nRestore a previously saved session:\n\n```typescript\nimport { PasswordSession, SessionData } from '@atproto/lex-password-session'\n\n// Load session from storage\nconst savedSession: SessionData = JSON.parse(localStorage.getItem('session')!)\n\n// Resume the session (automatically refreshes tokens)\nconst session = await PasswordSession.resume(savedSession, {\n  onUpdated: (data) => {\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: () => {\n    localStorage.removeItem('session')\n  },\n})\n\nconsole.log('Session resumed for:', session.did)\n\n// Access session properties\nconsole.log(session.did) // User's DID\nconsole.log(session.handle) // User's handle\nconsole.log(session.destroyed) // false (session is active)\n```\n\n> [!NOTE]\n>\n> `resume()` automatically calls `refresh()` to ensure the session is valid and tokens are current.\n\n### Logout\n\nEnd the session and notify the server:\n\n```typescript\nawait session.logout()\n```\n\nAfter logout:\n\n- The `onDeleted` hook is called\n- The session is marked as destroyed (`session.destroyed === true`)\n- Further requests will throw `'Logged out'`\n\n### Static Delete\n\nDelete a session without creating a session instance:\n\n```typescript\nimport { PasswordSession, SessionData } from '@atproto/lex-password-session'\n\nconst data: SessionData = JSON.parse(localStorage.getItem('session')!)\n\n// Delete the session on the server\nawait PasswordSession.delete(data)\n```\n\nThis is useful for cleanup scenarios where you don't need to make additional requests.\n\n### Create Account\n\nCreate a new account and get an authenticated session:\n\n```typescript\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.createAccount(\n  {\n    handle: 'alice.bsky.social',\n    email: 'alice@example.com',\n    password: 'secure-password',\n  },\n  {\n    service: 'https://bsky.social',\n    onUpdated: (data) => saveToStorage(data),\n    onDeleted: (data) => removeFromStorage(data.did),\n  },\n)\n\nconsole.log('Account created:', session.did)\n```\n\n## Session Hooks\n\nHooks provide callbacks for session lifecycle events. All hooks receive the session instance as `this` context.\n\n### onUpdated\n\nCalled when the session is successfully created or refreshed:\n\n```typescript\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n  onUpdated(data) {\n    // `this` is the PasswordSession instance\n    console.log('Session updated for:', this.did)\n\n    // Persist the updated session\n    saveSession(data)\n  },\n})\n```\n\n> [!IMPORTANT]\n>\n> Requests are blocked while `onUpdated` is running. Keep this callback fast to avoid delays.\n\n### onUpdateFailure\n\nCalled when token refresh fails due to transient errors (network issues, server unavailability):\n\n```typescript\n{\n  onUpdateFailure(data, error) {\n    console.warn('Token refresh failed:', error.message)\n    // Session may still be valid - consider retry logic\n  }\n}\n```\n\n### onDeleted\n\nCalled when the session is terminated (logout or server-side invalidation):\n\n```typescript\n{\n  onDeleted(data) {\n    console.log('Session ended for:', data.did)\n    clearPersistedSession(data.did)\n    redirectToLogin()\n  }\n}\n```\n\n### onDeleteFailure\n\nCalled when logout fails due to transient errors:\n\n```typescript\n{\n  onDeleteFailure(data, error) {\n    console.error('Logout failed:', error.message)\n    // Consider queuing for retry to avoid orphaned sessions\n    queueLogoutRetry(data)\n  }\n}\n```\n\n> [!WARNING]\n>\n> Ignoring delete failures can leave sessions active on the server. Implement retry logic for security-sensitive applications.\n\n## Session Data\n\nThe `SessionData` type contains all data needed to authenticate and restore sessions:\n\n```typescript\ntype SessionData = {\n  // Session credentials and user info from createSession response\n  accessJwt: string\n  refreshJwt: string\n  did: string\n  handle: string\n  email?: string\n  emailConfirmed?: boolean\n  didDoc?: object\n  // ... other fields from createSession\n\n  // Original service URL used for login\n  service: string\n}\n```\n\n## Error Handling\n\nThe `PasswordSession` class uses exception-based error handling:\n\n```typescript\nimport {\n  PasswordSession,\n  LexAuthFactorError,\n} from '@atproto/lex-password-session'\nimport { XrpcResponseError } from '@atproto/lex-client'\n\ntry {\n  const session = await PasswordSession.login({\n    service: 'https://bsky.social',\n    identifier: 'alice.bsky.social',\n    password: 'wrong-password',\n  })\n} catch (err) {\n  if (err instanceof LexAuthFactorError) {\n    console.error('2FA required')\n  } else if (err instanceof XrpcResponseError) {\n    switch (err.error) {\n      case 'AuthenticationRequired':\n        console.error('Invalid credentials')\n        break\n      case 'AccountTakedown':\n        console.error('Account has been suspended')\n        break\n      default:\n        console.error('Login failed:', err.message)\n    }\n  } else {\n    throw err\n  }\n}\n```\n\nCommon error codes:\n\n| Error Code                | Description                    |\n| ------------------------- | ------------------------------ |\n| `AuthenticationRequired`  | Invalid username or password   |\n| `AuthFactorTokenRequired` | 2FA code needed                |\n| `AccountTakedown`         | Account suspended              |\n| `ExpiredToken`            | Token has expired (on refresh) |\n| `InvalidToken`            | Token is invalid               |\n\n## Using with Client\n\nThe `PasswordSession` implements the `Agent` interface and can be used directly with `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport * as app from './lexicons/app.js'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n})\n\nconst client = new Client(session)\n\n// The client automatically uses the session for authentication\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: client.assertDid,\n})\n\n// Tokens are automatically refreshed when expired\nconst timeline = await client.call(app.bsky.feed.getTimeline, {\n  limit: 50,\n})\n\n// Create records\nawait client.create(app.bsky.feed.post, {\n  text: 'Hello from lex-password-session!',\n  createdAt: new Date().toISOString(),\n})\n```\n\nThe session handles:\n\n- Adding `Authorization` headers to requests\n- Detecting expired tokens (401 responses or `ExpiredToken` errors)\n- Automatically refreshing tokens and retrying failed requests\n- Routing requests to the correct PDS based on DID document\n\n## License\n\nMIT or Apache2\n","packages/lex/lex-resolver/AGENTS.md":"# AGENTS.md\n\n## Lexicon codegen: exception to the per-package `lexicons.json` convention\n\nThis package does **not** follow the repo-wide lexicon-management convention (a\nper-package `lexicons.json` manifest resolved with `lex install`; see\n[CONTRIBUTING → \"Working with lexicons\"](../../../CONTRIBUTING.md#working-with-lexicons)\nand the `lex-setup` skill). It has no `lexicons.json` and compiles the canonical\nschemas straight from the repo-root [lexicons/](../../../lexicons/):\n\n```\n\"codegen:lex\": \"lex-builder build --lexicons ../../../lexicons --out ./src/lexicons --clear --lib @atproto/lex-schema --include com.atproto.sync.getRecord --include com.atproto.lexicon.schema\"\n```\n\n**Why:** the installer (`@atproto/lex-installer`) depends on `@atproto/lex-resolver`,\nso this package cannot depend on the installer to manage a manifest without\ncreating a dependency cycle. It only needs canonical (`com.atproto.*`) schemas,\nwhich live in the root `lexicons` directory anyway, so compiling them directly is\nsufficient.\n\n**Do not** add a `lexicons.json`, `@atproto/lex`, or `@atproto/lex-installer`\nhere, and do not \"migrate\" this `codegen:lex` to the `lex install` form — keep the\n`lex-builder build --lexicons ../../../lexicons --include …` shape. Only\n`/src/lexicons` is gitignored (generated); there is no resolved `./lexicons`\ndirectory to manage.\n","packages/lex/lex-resolver/README.md":"# @atproto/lex-resolver\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nResolves [Lexicon](https://atproto.com/specs/lexicon) documents from the AT Protocol network by [NSID](https://atproto.com/specs/nsid).\n\n```typescript\nimport { LexResolver } from '@atproto/lex-resolver'\n\nconst resolver = new LexResolver({})\nconst { lexicon, uri, cid } = await resolver.get('app.bsky.feed.post')\n```\n\nResolution looks up the `_lexicon.<authority>` DNS TXT record to find the controlling [DID](https://atproto.com/specs/did), resolves that DID to a PDS, then fetches and cryptographically verifies the Lexicon record. Hooks allow caching at the fetch layer.\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`@atproto/lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) to implement `lex install`.\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/lex/lex-rpc/README.md":"# @atproto/lex-rpc\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nType-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) utilities for Lexicon-defined APIs: exposes low-level `xrpc()` / `xrpcSafe()` functions for making requests based on Lexicon derived schemas.\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/lex/lex-schema/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex-schema%2FREADME.md -->","packages/lex/lex-server/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex-server%2FREADME.md -->","packages/lex/lex/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex%2FREADME.md -->","packages/lexicon-resolver/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flexicon-resolver%2FREADME.md -->","packages/lexicon/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flexicon%2FREADME.md -->","packages/oauth/oauth-client-browser/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-browser%2FREADME.md -->","packages/oauth/oauth-client-browser/src/indexed-db/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-browser%2Fsrc%2Findexed-db%2FREADME.md -->","packages/oauth/oauth-client-expo/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-expo%2FREADME.md -->","packages/oauth/oauth-client-node/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-node%2FREADME.md -->","packages/oauth/oauth-client/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client%2FREADME.md -->","packages/oauth/oauth-provider-ui/CLAUDE.md":"# oauth-provider-ui\n\nUI for the OAuth provider's authorization & account-management screens. React 19, TanStack Router, Tailwind 4, shadcn/ui on Base UI, native-constraint forms on `@base-ui/react/form`, Lingui i18n.\n\n## Imports\n\n**Import from `react` by name — `import { useState, type ComponentProps }`.**\nNever `import * as React`, and never reach for the `React.*` global without an\nimport at all (`@types/react` declares it, so `React.ComponentProps` compiles\nwith no import and silently introduces a third style).\n\nThe exception is `components/ui/*`, which is shadcn CLI output and stays\nbyte-faithful — the registry emits `import * as React`, and rewriting it would\nput a diff in every file on the next `shadcn add`. The boundary is the\ndirectory: inside it, whatever the registry emits; everywhere else, named\nimports.\n\n## Verification\n\nType-check from the package directory:\n\n```bash\ncd packages/oauth/oauth-provider-ui\npnpm exec tsc --build tsconfig.json\npnpm test\npnpm run build:ui\n```\n\n**Run eslint from the repo root, not the package.**\n\n```bash\ncd /path/to/atproto\npnpm exec eslint --fix 'packages/oauth/oauth-provider-ui/src/**/*.tsx'\n```\n\nPrettier's Tailwind plugin reorders class strings. Accept its order.\n\n## Looking at the UI\n\n`pnpm dev:ui` serves all four entry pages on port 5174 against `src/mock-api.ts`\n— no PDS, no docker, no dev-env. Prefer it over booting the dev environment.\n\nThe account page starts signed out. The account rows in the picker are not\n`<button>`s — click `[aria-label^=\"Sign in as\"]`. The router uses real paths, not\nhashes, so navigate by clicking sidebar links rather than setting\n`location.hash`.\n\nEvery path under `/account` serves the account page, as it does behind the PDS\n(the `mock-account-paths` plugin in `vite.config.js`), so deep links and\nrefreshes work. That is what makes the mock usable for anything to do with URL\nstate — `account-page.html` must therefore keep its imports absolute and leave\nthe path alone when it is already under `/account/`.\n\n## Routing\n\n**File-based.** `src/routes/` is the route tree, and `src/routeTree.gen.ts` is\ngenerated from it by `@tanstack/router-plugin` on every dev run and build. The\ngenerated file is committed; don't edit it, and don't add it to a lint or format\npass (it is already in `.prettierignore`). `autoCodeSplitting` gives every route\ncomponent its own chunk — no `lazyRouteComponent` by hand.\n\nNaming follows TanStack's conventions: `__root.tsx`, `index.tsx` for a path's\nown page, a `$` prefix for params, `route.tsx` for a directory's layout, and a\n`-` prefix for files that are colocated content rather than routes (the About\npage's localized copies live in `-about/`).\n\n**Guards go in `beforeLoad`, never in render.** `/account` decides where the\ndevice's sessions belong (welcome / picker / the selected account) and\n`/account/u/$accountId` resolves the account the URL names, each throwing\n`redirect()`. `beforeLoad` returns `{ session }`, so descendants read the\naccount from `Route.useRouteContext()` instead of resolving it again. Rendering\na `<Navigate>` to gate a page reintroduces a render-then-bounce and a window\nwhere children fetch as the wrong account.\n\n**Router context** carries what the guards and loaders need but can't reach\nthrough React: `auth` (the session store), `api`, and `queryClient`. It is\nsupplied at `<RouterProvider context={…}/>` in `account-page.tsx`.\n\nTwo consequences that are easy to get wrong:\n\n- `auth` is `SessionContextType.store`, whose fields are **getters**, not a\n  render snapshot. A guard runs during a navigation that is usually dispatched\n  from the same event handler that just mutated the session, so a captured\n  value would be one render stale and the guard would reject the account it was\n  just sent to.\n- Changing the context does not re-run the guards. `account-page.tsx`\n  invalidates the router when the sessions change, which is what makes signing\n  out move the user with no navigation wired into the action.\n\nData pages load through the route: `loader` calls\n`queryClient.ensureQueryData(…QueryOptions(api, …))` and the component reads the\nsame query with `useSuspenseQuery`, so there is no in-component loading state.\nFailures surface through the route's `errorComponent` (whose `reset` retries).\n\nThe third-party consent flow has no router at all — it is a linear wizard and\nkeeps `AuthenticationProvider`.\n\nA link's `to` is a path _template_ (`/account/u/$accountId/manage`) — it never\nequals `location.pathname`. Test the current page with `useMatchRoute`\n(`useIsCurrentTarget` in `AccountShell`) and mark active links with\n`activeOptions={{ exact: true }}`; without `exact`, the account home matches\nevery page below it.\n\n## Components\n\n- `components/ui/*` — shadcn primitives, style `base-nova`, on `@base-ui/react`.\n  **Generated by the shadcn CLI, not hand-written.** Regenerate into a scratch\n  project and copy across, applying only these rewrites: `@/lib/utils` →\n  `#/lib/utils.ts`, `@/registry/…/ui/x` → `#/components/ui/x.tsx`, and drop any\n  `'use client'` directive (a Next.js RSC marker; this app is Vite). Keep these\n  files byte-faithful to upstream so future `shadcn add` stays mergeable — put\n  deviations in wrapper components instead. Base UI composes via\n  `render={<El/>}`, not a `asChild`-style prop.\n  The registry's components assume `@custom-variant` bridges that ship in the\n  style's `globals.css`, so copying `components/ui/*` alone is not enough — see\n  the orientation bridge in `src/style.css`. `Separator` silently rendered at\n  zero width/height until it was added.\n  Every file in there is a real registry entry — if `shadcn add <name>` under\n  this style returns an item with no files, the component is ours and belongs\n  outside this directory.\n- `components/forms/*` — `FormShell` (the form frame: action row, submit/cancel/\n  back, root error), `fields/*` (one wrapper per input type), plus\n  `AsyncButton`, `CopyButton`, `RequestCodeButton`, `SignUpWizard`.\n- `components/dialogs/*` — `DialogShell` (frame).\n- `components/feedback/*` — `Notice`, `ErrorNotice`, `ErrorCard`,\n  `ErrorDetails`, `PasswordStrength`.\n- `components/identity/*` — account/client avatars, names, identifiers, the\n  account menu.\n- `components/layouts/*` — `AuthShell` (the centred auth card) and\n  `AccountShell` (sidebar + content). These two own the whole page frame,\n  including the `<title>`, so they must never nest.\n- Pages under `src/pages/` and the four `*-page.tsx` entry points own contexts,\n  TanStack Query and routing.\n\n## Forms\n\nForms are native: `FormShell` (on `@base-ui/react/form`) plus one field wrapper\nper input. Validation is HTML constraint attributes on the controls —\n`required`, `type`, `pattern`, `minLength` — so an invalid submit is blocked by\nthe browser with its own bubble, in the browser's locale. There is no form\nlibrary, no schema layer, and no store: the DOM is the source of truth, and\n`FormShell` reads the values off the element with `FormData`.\n\n```tsx\n<FormShell<Values> onSubmit={(values, signal) => handler(values, signal)}>\n  <EmailField name=\"email\" required label={<Trans>Email</Trans>} />\n</FormShell>\n```\n\nShared regexes for `pattern` attributes live in `src/lib/form-patterns.ts`.\nNote `pattern` sources are implicitly anchored — no `^`/`$` — and compiled with\nthe `v` flag, under which a literal `-` in a character class must be escaped\n(`[a-z0-9\\-]`). A pattern that fails to compile is silently ignored by the\nbrowser (Chrome logs \"Pattern attribute value … is not a valid regular\nexpression\"), disabling the constraint. The pds `oauth.test.ts` asserts\n`validity.patternMismatch` on the sign-in identifier to catch this in a real\nbrowser.\n\nBase UI rules this layer depends on — each one was a real, browser-only bug:\n\n- **Values must come from `FormData`, never from `onFormSubmit`'s argument.**\n  Base UI only passes values for controls registered through `Field.Root`; a\n  plain named input (a checkbox, a hidden input) is silently absent. `FormShell`\n  already does this — don't \"simplify\" it back.\n- **`Form` defaults to `noValidate`.** `FormShell` re-enables the browser's\n  gate; without it, any control outside a `Field.Root` is validated by no one.\n- **A `Field.Root` binds every descendant control to its own name**, even over\n  an explicit `name` prop. A field composed of two controls (`NewHandleField`)\n  must opt out of `Field` entirely.\n- **A `disabled` control is omitted from form values.** Use `readOnly` when the\n  value must still submit.\n- **`type=\"email\"` accepts `user@host` with no dot** — `EmailField` carries a\n  `pattern` requiring a TLD on top.\n\n`FormShell` submits through `useAsyncAction`, which handles abort-on-unmount,\nabort-on-supersede and error retention. Errors render via `ErrorNotice` +\n`apiErrorParser`, so typed OAuth error payloads keep their user-facing messages.\nFor callers that must survive remounting (the sign-up wizard's back/forward),\n`onValues` mirrors the form's values on every edit.\n\n**Reserve space for anything that appears conditionally** next to an\ninteractive control — a layout shift between mousedown and mouseup lands the\nclick on a different element and silently swallows it.\n\n### Field names are a public contract\n\nThe `name` attribute on each control is selected on by the pds e2e suite and by\npassword managers. These names are owned by this package and must not be\nrenamed: `identifier`, `password`, `remember`, `token`, `emailOtp`, `email`,\n`handle`, `domain`, `inviteCode`.\n\nControls are named after the API field they feed (`identifier`, `token`,\n`emailOtp`), so values pass through without remapping. Keep it that way when\nadding one. Identifier fields also keep `autoComplete=\"username\"`, which is\nwhat marks them for password managers.\n\n## i18n (Lingui)\n\n- `.po` files under `src/locales/*` are generated; don't hand-edit except to add\n  French translations.\n- After any `.tsx` edit run `pnpm i18n`, then **check the diff for added or\n  removed `msgid`s**. Only line-reference churn is expected. This check has\n  caught two bugs that nothing else would have.\n- msgids are the English source strings, so changing a string orphans five\n  locales. `<Trans>` placeholders are positional (`<0>`, `<1>`) and derive from\n  JSX nesting — adding or reordering an element inside a `<Trans>` renumbers them\n  and changes the msgid. Recompose _around_ `<Trans>` blocks. Expression\n  placeholders are named by the identifier — `{minLength}` and `{MIN_LENGTH}`\n  are different msgids — so alias a renamed variable back rather than touching\n  the string.\n- **Never pass `t` as a prop.** The macro only transforms `` t`…` `` in a scope\n  that imports `useLingui` from `@lingui/react/macro`; passing `t` down leaves\n  the template uncompiled and the string untranslated, and it silently vanishes\n  from the catalogs.\n- A msgid that disappears and returns loses its translation. After extraction,\n  check the _count_ of untranslated entries, not just the msgid diff.\n- Fill in French only; other locales are translated externally.\n\n## The pds e2e contract\n\n`packages/pds/tests/{oauth,account-manager}.test.ts` drive this UI by text and\ntag, so markup choices are load-bearing. The test files themselves are the\nauthoritative record of which text sits in which element. The rules:\n\n- actions the tests click must be real `<button>`s\n- body copy in `<p>`, page/section headings in the tag the test names\n  (`h2`/`h3`), identifiers and radio labels in `<span>`, code samples in `<code>`\n- dialog content keeps `role=\"dialog\"` and a `button[type=\"submit\"]`\n- `CardDescription` and `AlertDescription` render `<div>`, so wrap asserted copy\n  in an explicit `<p>`. **`DialogDescription` does not** — Base UI's\n  `Dialog.Description` is already a `<p>`, and wrapping it produced `<p><p>`,\n  which React reports as invalid nesting. Check the primitive's rendered element\n  before adding a wrapper rather than assuming they match.\n\nDialogs must cap their height (`DialogShell` does): Base UI's `DialogContent` is\n`fixed` with no height limit, and a tall dialog on a short viewport puts its\nbuttons out of reach with no way to scroll to them.\n\n## Branding\n\n`CustomizationData` supplies the logo, name, footer links and\n`availableUserDomains`. Colours arrive separately, at runtime: the provider\ninjects `--branding-color-*` variables (see\n`oauth-provider/src/customization/build-customization-css.ts`), and the\n\"Runtime branding\" section of `src/style.css` maps `primary`, `error`, `info`,\n`warning` and `success` onto the shadcn tokens. Each is one injected RGB that\nconsumers tint with opacity modifiers, not the old multi-shade hue palette.\nWhen a deployment configures nothing the tokens fall back to the pre-redesign\nstock palette (a purple primary) — deliberately not Bluesky's, so an\nuncurated PDS is not mistaken for one.\n\n`branding.background` customises the auth screen's page area (behind the card,\nnot the card itself). It carries a light and a dark image URL; the provider\ninjects `--branding-background-{light,dark}-image` (wrapped in `url(...)`), and\nthe `.auth-background` rule in `src/style.css` paints that image over the neutral\n`muted` base, picking the mode by `prefers-color-scheme`. Unset, it falls back to\nthe plain `muted` surface it always was. The card keeps its own opaque surface so\ncopy stays legible over any background.\n\nThe mock pages inject the same variables a branded deployment would, so\n`pnpm dev:ui` demonstrates branding without a PDS.\n\n## Utility types\n\n`Override<A, B>` (from `#/lib/util.ts`) is the standard prop-extension pattern:\n\n```tsx\ntype Props = Override<JSX.IntrinsicElements['button'], { icon: LucideIcon }>\n```\n\nIcons are `lucide-react`.\n\n## Linting quirks\n\nESLint requires unused vars/args to match `/^_/u`. Drop the destructure rather\nthan prefixing when the prop is genuinely unused.\n","packages/oauth/oauth-provider-ui/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider-ui%2FREADME.md -->","packages/oauth/oauth-provider/src/lib/html/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider%2Fsrc%2Flib%2Fhtml%2FREADME.md -->","packages/oauth/oauth-provider/src/lib/http/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider%2Fsrc%2Flib%2Fhttp%2FREADME.md -->","packages/oauth/oauth-types/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-types%2FREADME.md -->","packages/ozone/AGENTS.md":"# Ozone moderation inbox\n\n- Reject wildcard `queryLabels` requests before accessing the DB unless `OZONE_LABEL_QUERY_WILDCARDS_ENABLED` is enabled; exact queries remain available.\n- Execute cancellable listing SQL with the HTTP signal and Kysely's `inflightQueryAbortStrategy: 'cancel query'`; a signal alone leaves PostgreSQL work running.\n- Reporter statistics seek recent non-appeal reports by reporter/time and look up distinct linked action IDs by primary key; never join reports to subject event history.\n\n- Read previews share `inboxViewerDid`; writes always use the authenticated DID.\n- Notification preference writes atomically patch only provided categories. Unset category columns inherit the retained push column to preserve existing opt-outs without a backfill; preferences do not suppress in-app inbox activity.\n- Produce moderation notifications from the common `ModerationService.logEvent` path so daemon actions are covered. Keep source snapshots in the source transaction, and isolate optional queries/inserts with `runNotificationWork` savepoints; a caught SQL error alone leaves PostgreSQL's transaction aborted.\n- Batch report notifications and skip non-transition activities before querying. Viewer responses never expose moderator note text.\n- Inbox appeal state and filters prioritize active reports; eligibility considers inbox appeal reports, including reopened and pre-cutoff history. Serialize inbox appeal submissions by subject; subject reviewState and appealed flags are not synchronized by report activities. Unread includes appeal report updates, independently of the requested sort field. Apply filters before pagination.\n- Appeal outcomes come from the current close activity and its linked corrective event, never merely nonempty actionEventIds (which also contain the appealed action). No-action appeal closures record denied in activity metadata; unknown historical closures omit the outcome. Resolution notifications snapshot the outcome.\n- Seek the latest appeal per requested subject with an indexed lateral LIMIT before joining its closure and resolution event; outcome work must be bounded by the page size, not appeal history.\n- Read `NEXT_PUBLIC_STRIKE_SUSPENSION_CONFIG` through configuration. The second and third sorted strike thresholds determine warning and atRisk; retain the full hours map, including Infinity.\n- Use explicit predicates for partial indexes on heavy tables. Report pagination seeks report.reporterDid/time indexes, validates source ownership before LIMIT by lateral primary-key lookup, and materializes only the page before hydration. Reporter backfill/catch-up is a deployment prerequisite.\n- OZONE_INBOX_START_AT bounds history by inclusive creation time before pagination/aggregation, including details and notification targets. Express the cutoff on the sort/seek index's own column (report updatedAt pages also bound `updatedAt`), join per-DID aggregates to statuses on indexed columns (`recordPath`), and bind the viewer DID as a constant in correlated `EXISTS` checks: PostgreSQL may hash such subplans and drop correlation. Keep live standing/enforcement and historical appeal eligibility authoritative; only display post-cutoff appeal history.\n- Migrations that use report.reporterDid must run after _20261002T000000000Z. Pre-create large indexes concurrently in production and verify they are valid and ready before running transactional migrations.\n","packages/ozone/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fozone%2FREADME.md -->","packages/pds/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fpds%2FREADME.md -->","packages/pds/src/scripts/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fpds%2Fsrc%2Fscripts%2FREADME.md -->","packages/repo/AGENTS.md":"# packages/repo\n\n## MST traversals MUST call `markVisited`\n\nEvery code path that can visit more than one child of an MST node — full-tree\nwalks, diffs, block collection; recursive or iterative; in `src/mst/mst.ts`,\n`walker.ts`, `diff.ts`, or new code anywhere — **MUST**:\n\n1. create a fresh `CidSet` per traversal (never share one between traversals),\n   and\n2. call `node.markVisited(seen)` on every `MST` node it enters, before reading\n   that node's entries (`getEntries`, `atIndex`, …).\n\nThis is a requirement, not an optimization: a traversal that skips it is a bug\neven if every test passes. When adding a traversal, add a matching case to\n`tests/mst-dag.test.ts`.\n\nExempt: lookups that follow a single path (one recursive call per level, e.g.\n`cidsForPath`, `proofForKey`), which are bounded by tree depth, and walks over\nin-memory nodes built by local mutations (outdated pointers, e.g. `serialize`),\nsince nodes loaded from storage never have one.\n\n`MST.markVisited` throws `VisitedCidError` on a repeated node and skips nodes\nwith an outdated pointer (not yet serialized).\n","packages/repo/CLAUDE.md":"# CLAUDE.md\n\n@AGENTS.md\n","packages/repo/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Frepo%2FREADME.md -->","packages/sync/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fsync%2FREADME.md -->","packages/syntax/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fsyntax%2FREADME.md -->","packages/tap/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Ftap%2FREADME.md -->","packages/ws-client/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fws-client%2FREADME.md -->","packages/xrpc-server/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fxrpc-server%2FREADME.md -->","packages/xrpc/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fxrpc%2FREADME.md -->"},"files":{".agents/skills/lex-client/SKILL.md":"---\nname: lex-client\ndescription: >\n  Call AT Protocol XRPC services with the `Client` from `@atproto/lex`. Use\n  when making an authenticated or unauthenticated HTTP request to an XRPC\n  service, wiring one service to call another, logging in with OAuth or an app\n  password, reading or writing repository records, uploading or fetching blobs,\n  configuring headers, labelers, service proxying, retries, or request/response\n  validation, handling XRPC errors, or composing reusable client actions. Also\n  use when replacing `AtpAgent`, `XrpcClient`, or a hand-rolled `fetch` call to\n  an `/xrpc/` endpoint. For a full migration off the legacy client stack, use\n  the lexification-client skill.\ndisable-model-invocation: false\n---\n\n# high-level XRPC client\n\n`Client` wraps an authenticated (or anonymous) session with helpers for AT Proto\nrepo operations (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`applyWrites`, `uploadBlob`, `getBlob`) plus the low-level\n`call`/`xrpc`/`xrpcSafe` request methods. Reach for it whenever there is a\nsession to carry, or per-service config (headers, labelers, proxy target) worth\nencapsulating.\n\nThe standalone `xrpc()` / `xrpcSafe()` exports exist for one-off, session-less\ncalls. Their first argument is an `Agent | AgentOptions` — a URL string, `URL`,\n`AgentConfig` object, or bare `FetchHandler` — and they otherwise share the same\noptions, responses, and errors as the client methods.\n\n```ts\nimport { xrpc } from '@atproto/lex'\n\nawait xrpc(hostUrl, com.atproto.sync.requestCrawl, { body: { hostname } })\n```\n\n## Constructing\n\nThe constructor takes an `Agent` (anything with `fetchHandler`, optionally\n`did`) or `AgentOptions` (a URL string/`URL`, or an `AgentConfig`).\n\n```ts\nimport { Client } from '@atproto/lex'\n\n// Unauthenticated\nconst client = new Client('https://public.api.bsky.app')\n\n// OAuth — OAuthSession implements Agent\nimport { type OAuthSession } from '@atproto/oauth-client-node'\nconst session: OAuthSession = await oauthClient.restore(userDid)\nconst client = new Client(session)\n```\n\n### App password sessions (CLI / scripts / bots)\n\n`PasswordSession` also implements `Agent`, plus `AsyncDisposable` — prefer\n`await using` so the session is deleted server-side on scope exit.\n\n```ts\nimport {\n  LexAuthFactorError,\n  PasswordSession,\n} from '@atproto/lex-password-session'\n\n// Credentials as a single URL: https://<handle>:<app-password>@<pds-host>\nawait using session = await PasswordSession.login(\n  process.env.APP_PASSWORD_CREDENTIALS,\n)\nconst client = new Client(session)\n```\n\nLong-lived apps should persist session data via `onUpdated` and use\n`PasswordSession.resume(data, options)` on the next run rather than re-sending\ncredentials. Other statics: `createAccount(body, { service, ... })` and\n`delete(data)` (revoke stored session data without resuming it). `login` throws\n`LexAuthFactorError` when the account requires 2FA — catch it, prompt, and retry\nwith `authFactorToken`.\n\n### Server-side calls with a static credential\n\nNo session needed — put the credential in the constructor headers. Internal\nservices typically also relax response processing:\n\n```ts\nconst client = new Client(\n  {\n    service: config.serviceUrl,\n    headers: { authorization: `Bearer ${config.serviceApiKey}` },\n  },\n  // Trust internal services to send well-formed responses\n  { strictResponseProcessing: false, validateResponse: config.devMode },\n)\n```\n\nIf the credential or URL must be resolved per-request, supply a custom agent:\n\n```ts\nconst agent: Agent = {\n  get did() {\n    return undefined\n  },\n  async fetchHandler(path, init) {\n    const { serviceUrl, serviceApiKey } = await getConfig()\n    const headers = new Headers(init?.headers)\n    headers.set('authorization', `Bearer ${serviceApiKey}`)\n    return fetch(new URL(path, serviceUrl), { ...init, headers })\n  },\n}\n\nconst client = new Client(agent)\n```\n\n`AgentConfig` and `fetchHandler` are mutually exclusive — passing both throws a\n`TypeError`.\n\n### Service proxying, and one client for several services\n\n`service` and `labelers` set the `atproto-proxy` / `atproto-accept-labelers`\nheaders. Both are _defaults_ and are overridable per request, so a single\nAppView-configured client can still reach the user's PDS directly — pass `null`\nto suppress the header for that request.\n\n```ts\nconst client = new Client(session, {\n  service: 'did:web:api.bsky.app#bsky_appview',\n  labelers: ['did:plc:labeler1', 'did:plc:labeler2'],\n})\n\n// Proxied to the AppView (client default)\nawait client.call(app.bsky.feed.getTimeline)\n\n// Straight to the user's PDS\nawait client.xrpc(com.atproto.repo.getRecord, {\n  service: null,\n  labelers: null,\n  appLabelers: null,\n  params: { repo: client.assertDid, collection, rkey: 'self' },\n})\n```\n\nThe record helpers (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`*Record`, `applyWrites`, `uploadBlob`, `getBlob`) already default to\n`service: null` / `labelers: null`, since repo operations belong on the PDS. Set\nthem explicitly to proxy one.\n\n## Validation options\n\n```ts\nconst client = new Client(session, {\n  // Validate request bodies against the input schema before sending. Worth\n  // enabling in dev/test to catch schema mistakes at the source.\n  validateRequest: true, // default: false\n\n  // Validate response bodies against the output schema.\n  validateResponse: false, // default: true\n\n  // Strict Lex decoding. `false` accepts legacy blobs, datetimes without\n  // timezones, etc. — see the lex-schema skill.\n  strictResponseProcessing: false, // default: true\n})\n```\n\nAll three are overridable per-call on `call` / `xrpc` / `xrpcSafe`.\n\n## Authentication accessors\n\n```ts\nclient.did // DidString | undefined\nclient.assertAuthenticated() // throws if unauthenticated; narrows client.did\nclient.assertDid // DidString (throws if unauthenticated)\n```\n\n`assertAuthenticated()` is an assertion function, so TypeScript requires the\nreceiver to have an explicit type annotation. `const client = new Client(...)`\n(inferred) will fail with TS2775 — write `const client: Client = new Client(...)`\nor annotate the parameter.\n\n## Requests\n\n### `client.call()` — returns the body, throws on failure\n\n```ts\nimport { app } from './lexicons/index.js'\n\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: 'pfrazee.com',\n})\n\nawait client.call(app.bsky.feed.sendInteractions, { interactions })\n\nconst timeline = await client.call(\n  app.bsky.feed.getTimeline,\n  { limit: 50 },\n  { signal, maxRetries: 3, headers: { 'custom-header': 'value' } },\n)\n```\n\nThe input argument is positional and distinct from the options: for a query it\nis the `params`, for a procedure the body, for an action the input. Methods that\ntake neither can be called with the schema alone, but **the options must never\noccupy the input slot** — `client.call(schema, options)` sends `options` as\nquery parameters and fails validation. Use `client.call(schema, {}, options)`,\nor `client.xrpc(schema, options)`.\n\n### `client.xrpc()` — full response\n\n```ts\nconst response = await client.xrpc(app.bsky.feed.getTimeline, {\n  params: { limit: 50 },\n  signal,\n  headers: { 'custom-header': 'value' },\n})\nresponse.status\nresponse.headers.get('content-language')\nresponse.body\n```\n\nUnlike `call`, `xrpc` takes a single options object with `params` / `body` as\nnamed fields, so options-only calls are unambiguous.\n\n### `client.xrpcSafe()` — discriminated result\n\nSame request as `xrpc()`, but returns the failure instead of throwing:\n\n```ts\nconst result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {\n  params: { handle: 'alice.bsky.social' },\n})\nif (result.success) {\n  result.body\n} else if (result.matchesSchemaErrors()) {\n  result.error // narrowed to the errors declared by the schema\n} else {\n  throw result.reason\n}\n```\n\n### Errors\n\nAll failures extend `XrpcError` (`error`, `message`, `matchesSchemaErrors()`,\n`shouldRetry()`). The three concrete branches of `XrpcFailure`:\n\n| Class                      | Meaning                                                                 |\n| -------------------------- | ----------------------------------------------------------------------- |\n| `XrpcResponseError`        | Server returned a non-2xx status                                        |\n| `XrpcInvalidResponseError` | Response is non-compliant — unexpected status, wrong encoding, bad data |\n| `XrpcInternalError`        | Failed before a usable response existed (network, client)               |\n\nSubclasses worth narrowing on: `XrpcAuthenticationError` (401, exposes\n`wwwAuthenticate`), `XrpcResponseValidationError` (response failed schema\nvalidation), and `XrpcFetchError` (the underlying `fetch` threw — the retryable\ninternal error).\n\nOnly `XrpcResponseError` carries HTTP data. `status`, `headers`, and `body` do\n**not** exist on the `XrpcFailure` union, so narrow first:\n\n```ts\nif (!result.success && result instanceof XrpcResponseError) {\n  result.status\n  result.headers.get('retry-after')\n}\n```\n\n### Retries\n\n`call`, `xrpc`, `xrpcSafe`, and the standalone functions all retry internally.\nSet `maxRetries` (default `0`); tune with `minRetryTimeout` (500ms),\n`maxRetryTimeout` (30s), `retryTimeoutFactor` (2), `retryHeaders` (honour\n`Retry-After` / `RateLimit-Reset`, default `true`), or replace the predicate with\n`retry(failure, { counter })`. The defaults already cover the retryable status\ncodes (408, 425, 429, 5xx) and network errors, so do not wrap calls in a manual\nretry loop.\n\n## Records\n\n### `create` / `get` / `put` / `delete`\n\n```ts\nimport { currentDatetimeString } from '@atproto/lex'\n\nconst { uri, cid } = await client.create(app.bsky.feed.post, {\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\n\n// Literal-key records (profile is rkey 'self') need no rkey\nconst profile = await client.get(app.bsky.actor.profile)\nprofile.value.displayName\n\nconst post = await client.get(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n\nawait client.put(app.bsky.actor.profile, { displayName: 'New Name' })\nawait client.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n```\n\n`rkey` is optional for `literal:` and auto-generated (`tid`) keys and required\notherwise; the type system enforces this, so a missing `rkey` shows up as a\ncompile error rather than a runtime one. Shared options: `validate` (ask the PDS\nto validate), `validateRequest` (validate locally before sending), `swapCommit`\n(optimistic concurrency on the commit CID), and for `put`/`delete` also\n`swapRecord` (on the record's own CID).\n\n### `list` / `listAll` — paginating a collection\n\nListed records are a discriminated union on `valid`: entries that fail schema\nvalidation still come back, with `value` widened to `LexMap`. Check `valid`\nbefore touching typed fields.\n\n```ts\nconst page = await client.list(app.bsky.feed.post, { limit: 50, reverse: true })\nfor (const record of page.records) {\n  if (record.valid) console.log(record.uri, record.value.text)\n}\nif (page.cursor) {\n  await client.list(app.bsky.feed.post, { cursor: page.cursor, limit: 50 })\n}\n```\n\n`listAll()` is the auto-paginating async generator — prefer it over hand-rolling\na cursor loop. It defaults to `maxRetries: 3` since a long walk is far more\nlikely to hit a transient failure:\n\n```ts\nfor await (const record of client.listAll(app.bsky.feed.post)) {\n  if (record.valid) console.log(record.value.text)\n}\n```\n\n### `applyWrites` — atomic batch writes\n\nAll ops succeed or fail together. Options: `repo` (defaults to `client.did`),\n`validate`, `swapCommit`.\n\n```ts\nconst response = await client.applyWrites((op) => [\n  op.create(app.bsky.feed.post, {\n    text: 'Hello!',\n    createdAt: currentDatetimeString(),\n  }),\n  op.update(app.bsky.actor.profile, { displayName: 'Alice' }),\n  op.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' }),\n])\n```\n\n`response.body.results` is **optional**, and is a union in which `deleteResult`\nhas no `uri` or `cid`. Narrow before reading them:\n\n```ts\nfor (const result of response.body.results ?? []) {\n  if (com.atproto.repo.applyWrites.createResult.$isTypeOf(result)) {\n    console.log(result.uri, result.cid)\n  }\n}\n```\n\nThe callback may also be a generator, which reads better for dynamic batches:\n\n```ts\nawait client.applyWrites(function* (op) {\n  for (const rkey of staleRkeys) yield op.delete(app.bsky.feed.post, { rkey })\n})\n```\n\n### Blobs\n\n```ts\nconst { body } = await client.uploadBlob(bytes, { encoding: 'image/png' })\nbody.blob // BlobRef — store this in a record\n\nconst image = await client.getBlob(did, cid)\n```\n\n## Labelers\n\n```ts\n// Process-global defaults, always sent with `;redact`\nClient.configure({ appLabelers: ['did:plc:labeler1'] })\n\n// Per-client, and mutable at runtime\nconst client = new Client(session, { labelers: ['did:plc:labeler3'] })\nclient.addLabelers(['did:plc:labeler4'])\nclient.setLabelers(['did:plc:labeler5'])\nclient.clearLabelers()\n```\n\nPass `appLabelers: null` on a request to drop the process-global ones. The usual\npost-login flow reads the user's preference:\n\n```ts\nconst { preferences } = await client.call(app.bsky.actor.getPreferences)\nconst pref = preferences.findLast(app.bsky.actor.defs.labelersPref.$isTypeOf)\nclient.setLabelers(pref?.labelers.map((l) => l.did) ?? [])\n```\n\n## Actions — composable client operations\n\nAn `Action<Input, Output>` is `(client, input, options) => Output | Promise<Output>`.\n`client.call()` accepts one anywhere a schema fits, so an action is\ninterchangeable with a lexicon method at the call site.\n\n```ts\nimport { type Action, Client, currentDatetimeString } from '@atproto/lex'\nimport { app } from './lexicons/index.js'\n\ntype LikeInput = { uri: AtUriString; cid: CidString }\n\nexport const likePost: Action<LikeInput, CreateOutput> = async (\n  client: Client, // explicit — required by assertAuthenticated() below\n  { uri, cid },\n  options,\n) => {\n  client.assertAuthenticated()\n  return client.create(\n    app.bsky.feed.like,\n    { subject: { uri, cid }, createdAt: currentDatetimeString() },\n    options,\n  )\n}\n\nawait client.call(likePost, { uri, cid })\n```\n\nActions compose — thread `options` through so cancellation propagates:\n\n```ts\nconst upsertPreference: Action<Preference, Preference[]> = async (\n  client,\n  pref,\n  options,\n) => {\n  const { preferences } = await client.call(\n    app.bsky.actor.getPreferences,\n    {},\n    options,\n  )\n  options?.signal?.throwIfAborted()\n  const updated = [...preferences.filter((p) => p.$type !== pref.$type), pref]\n  await client.call(\n    app.bsky.actor.putPreferences,\n    { preferences: updated },\n    options,\n  )\n  return updated\n}\n```\n\nExport actions individually rather than as one object, so consumers tree-shake\nwhat they don't call:\n\n```ts\n// actions.ts\nexport const post: Action</* ... */> = async (c, i, o) => {/* ... */}\nexport const follow: Action</* ... */> = async (c, i, o) => {/* ... */}\n\n// usage\nimport * as actions from './actions.js'\nawait client.call(actions.post, { text: 'Hello!' })\n```\n\nGuidelines: annotate as `Action<Input, Output>` (it contextually types the\nparameters); call `client.assertAuthenticated()` when auth is required;\n`options?.signal?.throwIfAborted()` between long steps; and handle swap-error\nretries here rather than pushing optimistic-concurrency handling onto callers.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  every client method, and `$isTypeOf` / `$build` / `$type`.\n- **[lex-data](../lex-data/SKILL.md)** — blobs, CIDs, branded strings, and\n  datetimes at the call boundary.\n- **[lexification-client](../lexification-client/SKILL.md)** — migrating from\n  `AtpAgent` / `XrpcClient`.\n- **[xrpc-server](../xrpc-server/SKILL.md)** — the other half, when the service\n  also _serves_ XRPC routes.\n- **[lex-setup](../lex-setup/SKILL.md)** — installing lexicons and configuring\n  the codegen that produces `./lexicons/index.js`.\n",".agents/skills/lex-data/SKILL.md":"---\nname: lex-data\ndescription: >\n  Represent, parse, serialize, hash, or convert AT Protocol data values with\n  `@atproto/lex`. Use whenever code touches CIDs, raw bytes, `$link` / `$bytes`\n  JSON, CBOR / DRISL encoding, blob references, or AT Protocol identifier and\n  datetime strings — DIDs, handles, AT URIs, NSIDs, TIDs, record keys,\n  `createdAt` / `indexedAt`. Applies when the ask sounds like \"serialize this\n  record\", \"hash these bytes\", \"why is this datetime rejected\", \"count the\n  characters in this field\", or when replacing `multiformats` CIDs,\n  `new Date().toISOString()`, or hand-rolled base64. Read it before typing a\n  DID, AT URI, or datetime as a bare `string`.\ndisable-model-invocation: false\n---\n\n# Data model: types, CIDs, JSON, CBOR, identifier strings, blobs\n\nThe AT Protocol data model is JSON plus two primitives — **CIDs**\n(content-addressed links) and **bytes** (raw binary) — encodable as JSON or as\nCBOR.\n\n## Which package exports what\n\n`@atproto/lex` re-exports `lex-data`, `lex-json`, `lex-schema` and\n`lex-client`, so a package that already depends on it needs no extra import\npath. `@atproto/lex-cbor` is **not** re-exported — depend on it explicitly.\n\n| Symbols                                                       | Defined in                                              | Reachable via `@atproto/lex` |\n| ------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------- |\n| `LexValue`, `Cid`, blob refs, base64/utf8 helpers, `LexError` | `@atproto/lex-data`                                     | yes                          |\n| `lexParse`, `lexStringify`, `jsonToLex`, `lexToJson`          | `@atproto/lex-json`                                     | yes                          |\n| Identifier & datetime string types + guards                   | `@atproto/syntax`, re-exported by `@atproto/lex-schema` | yes                          |\n| `encode`, `decode`, `decodeAll`, `cidForLex`                  | `@atproto/lex-cbor`                                     | **no**                       |\n\nLow-level packages (`repo`, `common`, `xrpc-server`) import `@atproto/lex-data`\ndirectly to avoid pulling in the client and schema layers.\n\n> [!NOTE]\n> This repo sets `verbatimModuleSyntax: true`, so type-only names need the\n> `type` modifier — `import { type DidString, isDidString } from '@atproto/lex'`.\n> All examples below follow that form.\n\n## Lex value types\n\n```ts\nimport {\n  type LexValue,\n  type TypedLexMap,\n  isLexValue,\n  isTypedLexMap,\n} from '@atproto/lex'\n\n// LexScalar:    number (integers only) | string | boolean | null | Cid | Uint8Array\n// LexValue:     LexScalar | LexValue[] | { [key: string]?: LexValue }\n// LexMap:       { [key: string]?: LexValue }\n// TypedLexMap:  LexMap & { $type: string }\n```\n\n`isLexValue`, `isLexMap`, `isLexArray` and `isLexScalar` take `unknown`;\n`isTypedLexMap` takes a `LexValue`, so narrow with `isLexValue` first when the\ninput is untyped. `isLexValue` walks the whole structure and rejects cycles,\nnon-integer numbers, and non-plain objects (`Date`, class instances,\nfunctions) — it is not a cheap check, so avoid it on hot paths where a schema\nalready validates the value.\n\n## CIDs\n\n`Cid` is an interface, not a class. `@atproto/lex-data` defines it rather than\nre-exporting `multiformats`' `CID`, because that class breaks under\n`node16` / `bundler` module resolution. Treat `CID` and `asMultiformatsCID` as\ndeprecated escape hatches.\n\n```ts\nimport {\n  type Cid,\n  asCid,\n  decodeCid,\n  ifCid,\n  isCid,\n  parseCid,\n  parseCidSafe,\n} from '@atproto/lex'\n\nparseCid(str) // Cid — throws on invalid\nparseCidSafe(str) // Cid | null\ndecodeCid(bytes) // Cid from binary form — throws on invalid\nisCid(value) // type guard\nifCid(value) // Cid | null\nasCid(value) // Cid — throws on invalid\n```\n\nEach accepts a `{ flavor }` option that additionally constrains the CID, which\nis how you enforce that a blob CID really is a blob CID rather than trusting\nthe caller:\n\n- `'raw'` — v1 + raw multicodec (blob content)\n- `'cbor'` — v1 + dag-cbor + sha256 (records, MST nodes)\n- `'dasl'` — either codec, v1 + sha256\n\nFor strings you already hold, `validateCidString(str, options)` returns a\nboolean and also checks the string is the canonical encoding;\n`ensureValidCidString` throws instead.\n\nCreating CIDs — hashing is async because it goes through WebCrypto:\n\n```ts\nimport {\n  cidForCbor,\n  cidForRawBytes,\n  cidForRawHash,\n  isCidForBytes,\n} from '@atproto/lex'\nimport { cidForLex } from '@atproto/lex-cbor'\n\nawait cidForLex(record) // CBOR-encode then hash — the usual entry point\nawait cidForCbor(cborBytes) // hash bytes that are already CBOR\nawait cidForRawBytes(blobBytes) // raw CID for blob content\ncidForRawHash(sha256Digest) // sync, when you already streamed the hash\nawait isCidForBytes(cid, bytes) // verify a CID matches its content\n```\n\nIn Lex JSON, CIDs are encoded as `{ \"$link\": \"bafyrei...\" }`.\n\n## Bytes\n\nBinary data is `Uint8Array`; JSON-encoded as `{ \"$bytes\": \"base64...\" }`.\n`@atproto/lex-data` ships the byte utilities so packages don't add their own\nbase64 dependency: `toBase64` / `fromBase64` (both take an optional\n`'base64' | 'base64url'` alphabet), `utf8ToBase64` / `utf8FromBase64`,\n`utf8FromBytes`, `ui8Concat`, `ui8Equals`, `asUint8Array`, `ifUint8Array`.\n\n## JSON ↔ Lex\n\n| Function       | Direction         | Input           | Output                                |\n| -------------- | ----------------- | --------------- | ------------------------------------- |\n| `lexParse`     | JSON string → Lex | `string`        | `LexValue` (with `Cid`, `Uint8Array`) |\n| `lexStringify` | Lex → JSON string | `LexValue`      | `string`                              |\n| `jsonToLex`    | parsed JSON → Lex | plain JS object | `LexValue`                            |\n| `lexToJson`    | Lex → plain JS    | `LexValue`      | plain JS object                       |\n\n`lexParse<T>()` takes a type parameter, so no `as` cast is needed:\n\n```ts\nimport { lexParse, lexStringify } from '@atproto/lex'\n\nconst sub =\n  lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(json)\nconst out = lexStringify({ ref: someCid, data: someBytes })\n```\n\n`lexParse` and `jsonToLex` accept `{ strict }`, defaulting to **`false`**:\nmalformed `$link` / `$bytes` objects and non-integer numbers pass through\nunchanged rather than throwing. Pass `{ strict: true }` when the input is\nuntrusted and a silently-wrong value would be worse than an exception.\n`lexParseJsonBytes(bytes, options)` is the same parse starting from UTF-8\nbytes.\n\nField-level encode/decode is rarely needed, but note the return contract —\nthese signal failure by returning `undefined`, they do not throw:\n\n```ts\nimport {\n  encodeLexBytes,\n  encodeLexLink,\n  parseLexBytes,\n  parseLexLink,\n} from '@atproto/lex'\n\nparseLexLink({ $link: 'bafy...' }) // Cid | undefined\nparseLexBytes({ $bytes: 'SGVsbG8=' }) // Uint8Array | undefined\nencodeLexLink(someCid) // { $link: '...' }\nencodeLexBytes(new Uint8Array([1, 2, 3])) // { $bytes: '...' }\n```\n\n## CBOR (DRISL)\n\nUse CBOR for repo storage, signed records, CAR files, and event frames —\nanywhere [DRISL](https://dasl.ing/drisl.html) deterministic encoding is\nrequired. Add `@atproto/lex-cbor` as a dependency; it is not reachable through\n`@atproto/lex`.\n\n```ts\nimport { type LexValue } from '@atproto/lex'\nimport { cidForLex, decode, decodeAll, encode } from '@atproto/lex-cbor'\n\nconst bytes: Uint8Array = encode(someLexValue)\nconst value = decode<{ foo: LexValue }>(bytes)\n\n// Concatenated values (CAR blocks, subscription frames)\nfor (const frame of decodeAll(buffer)) {\n  /* … */\n}\n```\n\n`encode` throws on anything the AT data model forbids: non-string map keys,\n`undefined`, and non-integer numbers. That strictness is the point — it is what\nmakes the resulting CID stable.\n\n## Datetime strings\n\n`DatetimeString` is the branded type for AT Protocol datetimes.\n`Date.prototype.toISOString()` is not guaranteed to conform (years outside\n0–9999 serialize with a `±YYYYYY` prefix), so route `Date` → string through\nthe helpers rather than calling `toISOString()` directly:\n\n```ts\nimport {\n  type DatetimeString,\n  asDatetimeString,\n  currentDatetimeString,\n  ifDatetimeString,\n  isDatetimeString,\n  toDatetimeString,\n} from '@atproto/lex'\n\ncurrentDatetimeString() // now\ntoDatetimeString(date) // throws InvalidDatetimeError on an out-of-range date\nasDatetimeString(str) // validates + brands a string, throws on invalid\nisDatetimeString(value) // type guard\nifDatetimeString(value) // DatetimeString | undefined\n```\n\n`isDatetimeStringLenient` accepts ISO-ish strings that the spec rejects (e.g.\nmissing a timezone) — use it when reading legacy records, not when writing.\nFor repairing bad historical `createdAt` values, `normalizeDatetime` /\n`normalizeDatetimeAlways` live in `@atproto/syntax` only; they are not\nre-exported from `@atproto/lex`.\n\nUse `DatetimeString` on record and DB row types so a raw `string` can't drift\nin:\n\n```ts\nimport type { DatetimeString, DidString } from '@atproto/lex'\n\ninterface Row {\n  did: DidString\n  indexedAt: DatetimeString\n}\n```\n\n## Identifier strings\n\nNominal string types keep unvalidated `string`s from being passed where a\nvalidated identifier is expected.\n\n| Type                 | Format                     | Guard                  |\n| -------------------- | -------------------------- | ---------------------- |\n| `DidString`          | `did:method:specific-id`   | `isDidString`          |\n| `HandleString`       | DNS-style handle           | `isHandleString`       |\n| `AtIdentifierString` | DID **or** handle          | `isAtIdentifierString` |\n| `AtUriString`        | `at://…`                   | `isAtUriString`        |\n| `UriString`          | any `scheme:…` URI         | `isUriString`          |\n| `NsidString`         | `app.bsky.feed.post`       | `isNsidString`         |\n| `TidString`          | timestamp identifier       | `isTidString`          |\n| `RecordKeyString`    | rkey (TID or literal)      | `isRecordKeyString`    |\n| `CidString`          | CID in string form         | `isCidString`          |\n| `LanguageString`     | BCP-47 tag (`en`, `pt-BR`) | `isLanguageString`     |\n| `DatetimeString`     | AT Proto datetime          | `isDatetimeString`     |\n\nOnly `AtUriString`, `AtIdentifierString` and `DatetimeString` also ship\n`as…` / `assert…` / `if…` variants. For the rest, the generic format helpers\ncover the same ground:\n\n```ts\nimport { asStringFormat, ifStringFormat, isStringFormat } from '@atproto/lex'\n\nisStringFormat(value, 'did') // narrows to DidString\nasStringFormat(value, 'nsid') // throws TypeError on invalid\nifStringFormat(value, 'handle') // HandleString | undefined\n```\n\n`isAtUriString`, `isLanguageString` and `isStringFormat` accept\n`{ strict: false }` for a lenient pass that tolerates non-conforming\nreal-world data.\n\n> [!NOTE]\n> These types are defined in `@atproto/syntax` and re-exported through\n> `@atproto/lex`. Import from `@atproto/lex` where the package already depends\n> on it — that keeps identifier types and generated schemas coming from one\n> place and avoids adding a `@atproto/syntax` dependency.\n\n### Boundaries\n\nProtobuf messages, data-plane responses and Kysely rows all arrive as bare\n`string`. Brand them once at the boundary so downstream code is typed, instead\nof asserting at each use:\n\n```ts\ndids: dids as DidString[],\npost: { uri: item.uri as AtUriString, cid: item.cid || undefined },\n```\n\nAn `as` cast is appropriate where the value provably came from a validated\nsource (your own DB, a schema-validated response). Where it did not — env\nvars, CLI args, user input — run the guard: `assert(isDidString(serverDid))`.\n\n## Blob references\n\nTwo shapes coexist on the network:\n\n```ts\ntype TypedBlobRef = { $type: 'blob'; ref: Cid; mimeType: string; size: number }\ntype LegacyBlobRef = { cid: string; mimeType: string } // no $type, no size\ntype BlobRef = TypedBlobRef | LegacyBlobRef\n```\n\nNew uploads are always `TypedBlobRef` — the PDS `uploadBlob` endpoint returns\none. Reading code has to tolerate both, which is what the format-agnostic\naccessors are for:\n\n```ts\nimport {\n  type BlobRef,\n  enumBlobRefs,\n  getBlobCid,\n  getBlobCidString,\n  getBlobMime,\n  getBlobSize,\n  isLegacyBlobRef,\n  isTypedBlobRef,\n} from '@atproto/lex'\n\ngetBlobCid(blob) // Cid — parses the string for legacy refs, so it can throw\ngetBlobCidString(blob) // string — skips the parse, prefer it when you only need the string\ngetBlobMime(blob) // string\ngetBlobSize(blob) // number | undefined — legacy refs carry no size\n\n// Deep-walk a record for its blobs (what the PDS does on record write/import)\nfor (const ref of enumBlobRefs(record, { allowLegacy: true, strict: false })) {\n  /* … */\n}\n```\n\n`enumBlobRefs` skips legacy refs unless `allowLegacy: true`.\n\n`BlobRef` is an interface, so `instanceof` does not apply — use `isBlobRef` /\n`isTypedBlobRef` / `isLegacyBlobRef`.\n\n### What `strict` means for blobs\n\nThe word means different things at two layers, and conflating them causes\nconfusing validation failures:\n\n- On the **guards** (`isBlobRef`, `isTypedBlobRef`, `isLegacyBlobRef`,\n  `enumBlobRefs`), `strict` constrains the _CID flavor_. Default `true`\n  requires a raw v1 sha256 CID; `strict: false` accepts any CID (and tolerates\n  `size: -1`). It does not reject the legacy shape — `isBlobRef` accepts legacy\n  refs either way.\n- On **schema validation** (`l.blob()`), `strict: true` (the default) rejects\n  legacy refs outright and enforces `accept` / `maxSize`; `strict: false`\n  admits them and skips those checks.\n\nA `Client` constructed with `strictResponseProcessing: false` threads\n`strict: false` through both response parsing and schema validation, which is\nhow you read a server that still emits legacy refs.\n\n## String lengths\n\nLexicon string constraints are measured in graphemes (user-perceived\ncharacters) or UTF-8 bytes, never in JS string length:\n\n```ts\nimport { graphemeLen, utf8Len } from '@atproto/lex'\n\ngraphemeLen('👨‍👩‍👧‍👦') // 1\nutf8Len('👨‍👩‍👧‍👦') // 25\n```\n\n## Comparison\n\n`lexEquals(a, b)` deep-compares two `LexValue`s, handling `Cid` and\n`Uint8Array` where `===` and `JSON.stringify` would not.\n\n## Related skills\n\n- [lex-schema](../lex-schema/SKILL.md) — validating these values against\n  Lexicon schemas.\n- [lex-setup](../lex-setup/SKILL.md) — adding `@atproto/lex-cbor` and the rest\n  of the package family to a package.\n- [lexification-client](../lexification-client/SKILL.md) — migrating off\n  `multiformats` `CID`, the `BlobRef` class from `@atproto/lexicon`, and\n  `jsonStringToLex` / `stringifyLex`.\n",".agents/skills/lex-schema/SKILL.md":"---\nname: lex-schema\ndescription: >\n  Validate, parse, construct, and type AT Protocol data against Lexicon schemas\n  with `@atproto/lex` / `@atproto/lex-schema`. Use whenever code reaches into a\n  generated `./lexicons/` tree — `$parse`, `$safeParse`, `$validate`,\n  `$matches`, `$assert`, `$build`, `$isTypeOf`, `$type`, `$nsid`, `$lxm`,\n  `$Params`, `$Input`, `$Output`, `$Message`, `.Main` — when checking whether an\n  unknown record or XRPC payload conforms, when loosening checks with\n  `strict: false`, when declaring a schema by hand with the `l` builder, when\n  handing a Lexicon schema to a Standard Schema consumer, or when replacing\n  Zod/Ajv-style validation with Lexicon validation. Not for codegen setup (see\n  lex-setup) nor for the value types themselves — CIDs, bytes, blobs, JSON/CBOR\n  encoding, branded strings (see lex-data).\ndisable-model-invocation: false\n---\n\n# Generated schemas and the `l` builder\n\n`lex build` compiles each Lexicon document into a TypeScript module carrying\nboth a runtime schema and its types, addressed by NSID dot-path through the\nnamespace tree:\n\n```ts\nimport { app, com } from './lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def schema\ncom.atproto.repo.createRecord // query/procedure schema\n```\n\nThe tree lives in `./src/lexicons/` (plural), is gitignored, and is regenerated\nby `lex build` — never edit it. See [lex-setup](../lex-setup/SKILL.md).\n\n## Accessors on a generated namespace\n\n| Accessor                | Schema kind             | What it is                                          |\n| ----------------------- | ----------------------- | --------------------------------------------------- |\n| `$nsid`                 | all                     | NSID of the document (`'app.bsky.feed.post'`)       |\n| `$type`                 | record / typed object   | `$type` string; equals `$nsid` for `main`           |\n| `$lxm`                  | query / procedure / sub | XRPC method ID — auth checks, proxy routing         |\n| `$params` / `$Params`   | query / procedure / sub | Params schema / its parsed type                     |\n| `$input` / `$Input`     | procedure               | Request payload schema / `{ encoding, body }` type  |\n| `$InputBody`            | procedure               | Request body type alone                             |\n| `$output` / `$Output`   | query / procedure       | Response payload schema / `{ encoding, body }` type |\n| `$OutputBody`           | query / procedure       | Response body type alone                            |\n| `$message` / `$Message` | subscription            | Message schema / message type                       |\n| `.Main`                 | record / object         | The main definition's TS type                       |\n\n`$Input` / `$Output` / `$InputBody` / `$OutputBody` take an optional binary-body\ntype parameter (`$Output<Buffer>`); the default is the opaque `l.BinaryData`\nplaceholder.\n\nSub-definitions sit alongside `.Main` — type in PascalCase, schema in camelCase:\n\n```ts\ntype ReplyRef = app.bsky.feed.post.ReplyRef\napp.bsky.feed.defs.postView // schema\ntype PostView = app.bsky.feed.defs.PostView // type\n```\n\n**Never write NSID string literals.** Use `$type` / `$lxm` / `$token`: the\nschema stays the single source of truth and each constant is one shared string\ninstance (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md#lexicons)). Tokens are\nalso available as PascalCase export (e.g., `app.bsky.feed.defs.RequestLess`),\nprefer this over the `$token` notation.\n\n## Validation methods\n\nEvery schema exposes these as plain methods; generated `main` definitions also\nre-export them as pre-bound `$`-prefixed consts, so\n`const { $parse } = app.bsky.feed.post` and\n`export const validateStrongRef = com.atproto.repo.strongRef.$safeValidate`\nboth work.\n\n| Method                       | On invalid  | Transforms input?      | Returns                        |\n| ---------------------------- | ----------- | ---------------------- | ------------------------------ |\n| `$parse(data, opts?)`        | throws      | yes                    | parsed value (output type)     |\n| `$safeParse(data, opts?)`    | result      | yes                    | `{ success, value }` \\| error  |\n| `$validate(data, opts?)`     | throws      | no                     | the _same_ value, narrowed     |\n| `$safeValidate(data, opts?)` | result      | no                     | `{ success, value }` \\| error  |\n| `$assert(data, opts?)`       | throws      | no                     | `void`, narrows via `asserts`  |\n| `$check(data, opts?)`        | throws      | no                     | `void`, no narrowing           |\n| `$cast(data, opts?)`         | throws      | no                     | same as `$validate`            |\n| `$matches(data, opts?)`      | `false`     | no                     | type-guard boolean             |\n| `$ifMatches(data, opts?)`    | `undefined` | no                     | the value, or `undefined`      |\n| `$isTypeOf(value)`           | `false`     | no — **no validation** | type-guard on `$type` only     |\n| `$build(data)`               | n/a         | adds `$type` only      | typed literal, **unvalidated** |\n\n### Parse vs. validate\n\nThe distinction is whether the schema may hand back a _different_ value.\n`$parse` runs in parse mode: `withDefault` defaults are filled in, `l.bytes()`\nnormalizes any typed-array view to `Uint8Array`, a `TokenSchema` instance is\naccepted in place of its string, and objects/arrays/params are copied on write\nwhen a child transforms. `$validate` runs the same checks but treats any\ntransformation as a failure, so a success guarantees the input already\nconformed as-is.\n\nThat guarantee is why validate mode is the right choice on the read path: the\nAppView hydrates stored records with `$matches` / `$safeValidate` rather than\n`$parse`, because a value with defaults applied would no longer hash to the CID\nit was stored under.\n\nParse mode does **not** decode the Lex JSON encoding — `{ \"$link\": … }` stays a\nplain object and fails CID validation. Convert first with `jsonToLex` /\n`lexParse` from `@atproto/lex-json` (see [lex-data](../lex-data/SKILL.md)).\n\n### Result shape\n\nOn failure the result _is_ a `LexValidationError` (the class implements the\nfailure shape), so `result.reason` is the error itself and `result.issues` is\nthe issue list. There is no `.error` payload object — `error` is the XRPC error\ncode string (`'InvalidRequest'`).\n\n```ts\nconst result = app.bsky.feed.post.$safeParse(unknownData)\nif (result.success) {\n  result.value // app.bsky.feed.post.Main\n} else {\n  result.reason // LexValidationError (=== result)\n  result.issues // readonly Issue[], each carrying a path\n}\n```\n\n### `$assert` vs `$check`\n\n`$assert` narrows via an `asserts` signature, which TypeScript only permits when\nthe call target has an explicit type annotation. In generic code that yields:\n\n> Assertions require every name in the call target to be declared with an\n> explicit type annotation. `ts(2775)`\n\n`$check` is the same runtime check without the narrowing — reach for it only to\nresolve that error.\n\n```ts\ndeclare const schema: RecordSchema | ObjectSchema\nschema.$check(data) // void, no ts(2775)\n```\n\n### `$isTypeOf` — discriminate, don't validate\n\nOnly compares `$type`, so it is cheap enough for hot `.find` / `.filter` loops\nover already-validated unions:\n\n```ts\nconst pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nMind the asymmetry: on a **record** schema it demands an exact `$type` match,\nbut on a **typed object** (def) schema a _missing_ `$type` also passes, since\n`$type` is optional on nested defs. On unvalidated data it proves nothing about\nthe rest of the shape.\n\n### `$build` — construct with `$type`, no validation\n\nRecords require `$type`, so hand-building one means repeating the NSID.\n`$build` sets it and types the result; it applies no defaults and runs no\nchecks, so follow with `$parse` when the input is untrusted.\n\n```ts\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello, world!',\n  createdAt: l.currentDatetimeString(),\n})\n```\n\nDefs whose `$type` is optional need no runtime call — annotate instead. Use\n`Un$Typed<T>` when a helper returns a def's fields and the caller adds `$type`:\n\n```ts\nconst view: app.bsky.feed.defs.PostView = {/* ... */}\nfunction toPrefs(): Un$Typed<app.bsky.notification.defs.Preferences> {\n  /* ... */\n}\n```\n\n### Record keys\n\nA record schema carries its key schema, for validating an rkey independently of\nthe record body:\n\n```ts\nconst result = app.bsky.feed.post.keySchema.safeValidate(rkey)\n```\n\n## Strict mode\n\nEvery validating method takes `{ strict?: boolean, path?: PropertyKey[] }`,\nstrict defaulting to `true`. Strict means \"conforms to the AT Protocol spec\";\nlenient exists because data already on the network predates parts of it.\n\n| Concern         | `strict: true` (default)                  | `strict: false`                             |\n| --------------- | ----------------------------------------- | ------------------------------------------- |\n| `datetime`      | timezone required                         | any ISO-ish datetime                        |\n| `at-uri`        | record-key component validated            | record key not validated                    |\n| `language`      | RFC 5646 §4.1 semantics enforced          | well-formed BCP 47 syntax only              |\n| blob constraint | `accept` MIME list and `maxSize` enforced | not enforced                                |\n| blob ref CID    | strict CID check                          | any valid CID                               |\n| legacy blob ref | `{ cid, mimeType }` rejected              | accepted, including the sentinel `size: -1` |\n\n```ts\nschema.$safeParse(data, { strict: false })\n```\n\n`Client`'s `strictResponseProcessing: false` threads this through every\nresponse — prefer it over sprinkling per-call options. See\n[lex-client](../lex-client/SKILL.md).\n\n## Tokens\n\nA Lexicon `token` def exports its schema in camelCase and its string value in\nPascalCase. The PascalCase export shares its name with the string literal type:\n\n```ts\napp.bsky.graph.defs.curatelist // TokenSchema\napp.bsky.graph.defs.Curatelist // 'app.bsky.graph.defs#curatelist'\ntype Curatelist = app.bsky.graph.defs.Curatelist\n```\n\n## Declaring schemas with `l`\n\nGenerated schemas cover anything backed by a Lexicon document. Use the `l`\nbuilder for one-off internal shapes, or when authoring a schema before its\nLexicon JSON exists — otherwise prefer `./lexicons/`, which stays in sync with\nthe contract.\n\n```ts\nimport { l } from '@atproto/lex'\n\nconst thing = l.typedObject(\n  'com.example.thing', // NSID\n  'main', // definition name\n  l.object({\n    name: l.string({ maxGraphemes: 64 }),\n    count: l.optional(l.withDefault(l.integer({ minimum: 0 }), 0)),\n  }),\n)\n\ntype Thing = l.Infer<typeof thing> // input type; l.InferOutput for post-parse\n```\n\nNote the shape: document builders take `(nsid, name, validator)` — a validator,\nnot a bare property map. `l.object()` is what turns a property map into one.\n\n- Primitives — `l.string()`, `l.integer()`, `l.boolean()`, `l.bytes()`,\n  `l.cid()`, `l.blob()`, `l.null()`, `l.never()`, `l.unknown()`, `l.lexMap()`,\n  `l.lexValue()`\n- Composites — `l.object()`, `l.array()`, `l.dict()`, `l.union()`,\n  `l.discriminatedUnion()`, `l.intersection()`, `l.ref()`, `l.literal()`,\n  `l.enum()`, `l.regexp()`, `l.refine()`, `l.custom()`\n- Modifiers — `l.optional()`, `l.nullable()`, `l.withDefault()`\n- Lexicon documents — `l.record()`, `l.typedObject()`, `l.typedRef()`,\n  `l.typedUnion()`, `l.token()`, `l.query()`, `l.procedure()`,\n  `l.subscription()`, `l.params()`, `l.payload()`, `l.jsonPayload()`\n\n`l` also carries the schema classes (`RecordSchema`, `ObjectSchema`, …), the\ninference helpers (`l.Infer`, `l.InferInput`, `l.InferOutput`), the branded\nstring types and format guards, and the datetime helpers\n(`l.currentDatetimeString`, `l.toDatetimeString`, `l.asDatetimeString`,\n`l.isDatetimeString`, `l.ifDatetimeString`).\n\n`@atproto/lex` re-exports all of it flat as well, but importing `l` avoids\ncolliding with globals and generic names (`string`, `object`, `record`).\n\n`ParamsSchema` additionally exposes `fromURLSearchParams()` (coercing each\nstring to the declared scalar type) and `toURLSearchParams()` — use these\ninstead of hand-rolled query-string handling.\n\n## Standard Schema\n\nEvery schema implements [Standard Schema](https://standardschema.dev/) v1 via\n`~standard`, so form libraries and validators speaking the spec accept a\nLexicon schema directly:\n\n```ts\nconst result = app.bsky.feed.post['~standard'].validate(data)\nif (!result.issues) result.value\n```\n\nThe adapter always runs in **parse mode**, so defaults and coercion apply even\nwhere a caller might expect a pure check.\n\n## Related skills\n\n[lex-setup](../lex-setup/SKILL.md) generates this tree;\n[lex-data](../lex-data/SKILL.md) covers the values these schemas validate\n(CIDs, bytes, blobs, JSON/CBOR, branded strings);\n[lex-client](../lex-client/SKILL.md) and\n[xrpc-server](../xrpc-server/SKILL.md) cover passing schemas to calls and\nroutes.\n",".agents/skills/lex-setup/SKILL.md":"---\nname: lex-setup\ndescription: >\n  Wire up, maintain, or troubleshoot `@atproto/lex` code generation and the\n  per-package `lexicons.json` manifest. Use when a package needs generated\n  Lexicon schemas; when adding or editing a `lexicons.json`, its `resolvers`\n  (`directory` / `http` / `repo`) or a `lexicons-overrides` directory; when\n  migrating a package off compiling the repo-root `lexicons` directory; when\n  adding a `codegen:lex` / `lex:install` / `prebuild` script; when `./src/lexicons/`\n  or `./lexicons/` is missing, stale, or gitignored wrong; after editing anything\n  under `lexicons/`; when adding an NSID; when `pnpm codegen`, `lex build`, or\n  `lex install` fails; when `lex` resolves to the wrong binary; when a\n  post-lexicon-edit build reports \"type X is not assignable to Y\"; or when ripping\n  out legacy `@atproto/lex-cli` codegen. Reach for it before hand-editing anything\n  under a `lexicons/`, `lexicons-overrides/`, or `src/lexicons/` directory, or any\n  `lexicons.json`.\ndisable-model-invocation: false\n---\n\n# `@atproto/lex` codegen setup\n\nTwo commands do everything:\n\n- **`lex build`** — compiles Lexicon JSON into a TypeScript schema tree. Every\n  package that uses `@atproto/lex` runs this.\n- **`lex install`** — resolves the Lexicons a package's `lexicons.json` lists\n  into `./lexicons/`, recording each one's source + CID, from the manifest's\n  `resolvers` and the network.\n\nBoth ship in the `lex` bin of `@atproto/lex`. Add `@atproto/lex` as a\ndependency — it re-exports `@atproto/lex-client`, `-schema`, `-data`, and\n`-json`, so one dependency covers the common case. `@atproto/lex-cbor` is\n_not_ re-exported; depend on it directly if the package encodes/decodes CBOR.\n\n## Wiring a package in this monorepo\n\n[lexicons/](../../../lexicons/) at the repo root is the canonical source for the\n`com.atproto` and `tools.ozone` namespaces **only**. Every package manages its\nLexicons through its own committed `lexicons.json` manifest and resolves them\nwith `lex install` — no package compiles the root directory directly. The\nmanifest's `resolvers` decide where each NSID comes from; see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson).\n\nCopy the shape consuming packages use\n([packages/pds](../../../packages/pds/package.json),\n[packages/sync](../../../packages/sync/package.json),\n[packages/bsky](../../../packages/bsky/package.json)):\n\n```json\n{\n  \"scripts\": {\n    // Generate TypeScript schema from Lexicon JSON\n    \"lex:build\": \"lex build --clear --indexFile\",\n    // Install Lexicon JSON into ./lexicons/ and update the manifest. Use `pnpm lex:install foo.bar.baz` to install new lexicons\n    \"lex:install\": \"lex install\",\n    // Keep the TypeScript schema up to date after installing new lexicons\n    \"postlex:install\": \"pnpm run lex:build\",\n    // Allows to keep the manifest.json up-to-date when a canonical lexicon is updated. Also allows to fetch new lexicons from the network.\n    \"lex:update\": \"lex update\",\n    // Run the build step after updating the lexicons\n    \"postlex:update\": \"pnpm run lex:build\",\n    // When performing a regular build (eg. from CI), ensure that the lexicons are installed and match the manifest.\n    \"codegen:lex\": \"pnpm run lex:install --ci\",\n    \"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n    \"build\": \"tsc --build tsconfig.build.json\"\n  }\n}\n```\n\nThen gitignore the build artifacts. The generated TypeScript (`src/lexicons`) is\nnever committed, and the canonical schemas symlinked into `./lexicons` from the\nrepo root (`com.atproto` / `tools.ozone`) are gitignored too — they can't drift\nand are recreated on every install. Everything else resolved into `./lexicons` is\n**committed**: the `at://` network copies and the `app.bsky.*` / `chat.bsky.*`\ndocuments fetched from the bsky GitHub repo, so builds and CI work offline (the\nnetwork can't serve old record versions). The shared repo-root\n[lexicons-overrides/](../../../lexicons-overrides/) is committed too. See\n[packages/bsky/.gitignore](../../../packages/bsky/.gitignore) for the shape:\n\n```\n# @atproto/lex\n/src/lexicons\n# Canonical schemas are symlinked from the repo-wide source; everything else\n# under ./lexicons is a committed copy kept for offline builds and CI.\n/lexicons/com/atproto\n/lexicons/tools/ozone\n```\n\nA package whose Lexicons are _all_ symlinked canonical (e.g.\n[packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json))\nhas nothing to commit under `./lexicons` and can gitignore the whole `/lexicons`\ntree instead.\n\nThe indirection through `codegen:lex` is deliberate:\n\n- `prebuild` runs every `codegen:*` script the package declares, so a package\n  that also generates protobuf or templates needs no change to this line.\n  `enable-pre-post-scripts = true` in `.npmrc` is what makes a build fire\n  `prebuild` at all.\n- Root `pnpm codegen` runs `build:tooling` and then every `codegen:*` script\n  across the workspace in parallel. A generator hidden inside `prebuild` would\n  be skipped by it.\n\n### Which `lex` binary you get\n\n`@atproto/lex-cli` (legacy) and `@atproto/lex` both claim the `lex` bin name,\nand resolution is per-package. In `packages/api` — which still depends on\n`lex-cli` for `gen-api` — `pnpm exec lex` gives you the **legacy** CLI, not\n`lex build`. `api` therefore installs its manifest with the `lex-installer`\nbin (`@atproto/lex-installer`) and feeds the installed `./lexicons` to\n`gen-api`. Everywhere else `lex` is `@atproto/lex`.\n\n`gen-api` is slow, so `api`'s `lex:gen` is mtime-guarded: it only re-runs when\n`./src/client/index.ts` is missing or some lexicon content is newer than it.\nUse `find -L ./lexicons -type f` (not the symlinks themselves) — `lex install`\nre-creates the canonical symlinks and rewrites `lexicons.json` on every run, so\ntheir mtimes are meaningless; `-L` follows each symlink to its (stable) target,\nand the regular files are the committed/fetched copies.\n\nIf a `lex build` invocation errors about an unknown command, that collision is\nwhy. `@atproto/lex` also registers `ts-lex`, which is unambiguous. Outside a\nworkspace, `npx @atproto/lex` avoids pulling the unrelated unscoped `lex`\npackage that bare `npx lex` would fetch.\n\n### Manifest resolvers (`lexicons.json`)\n\nThe manifest's `lexicons` array lists the NSID **entry points** the package\nneeds; `lex install` pulls in schemas they reference transitively, so you only\nlist the roots. `resolvers` is an **ordered** list deciding where each NSID is\nfetched from — earlier entries win, with the AT Protocol network as the implicit\nlast fallback. The kinds:\n\n- `{ \"type\": \"directory\", \"path\": \"../../lexicons\", \"include\": [\"com.atproto.*\", \"tools.ozone.*\"] }`\n  — resolve from a local directory. The canonical set lives at the repo root;\n  matches are **symlinked** into `./lexicons` and locked with a `file://` URI, so\n  they can't drift. Adjust `path` for the package's depth.\n- `{ \"type\": \"http\", \"base\": \"https://raw.githubusercontent.com/bluesky-social/bsky/refs/heads/main/lexicons/\", \"include\": [\"app.bsky.*\", \"chat.bsky.*\"] }`\n  — fetch `<base>/<…nsid.segments>.json` (e.g. a raw Git host). `http` falls\n  through on 404 but throws on other errors.\n- `{ \"type\": \"repo\", \"repo\": \"<handle|did>\" }` — resolve each NSID from that\n  repo's `com.atproto.lexicon.schema` records; never falls through.\n- A `directory` resolver pointing at the shared, committed repo-root\n  **`../../lexicons-overrides`** directory, listed **after** the canonical\n  resolver but **before** the `http`/network ones, so it overrides the lexicons\n  they would otherwise return (it cannot override canonical, which resolves\n  first). Put any document that must differ here: unpublished NSIDs (`internal.*`,\n  `place.stream.badge.defs`, a not-yet-published `app.bsky.unspecced.*`),\n  documents whose local version is ahead of the published one, and minimal\n  compatibility relaxations of published third-party lexicons that network\n  records violate (see\n  [Compatibility overrides](../../../lexicons-overrides/README.md#compatibility-overrides);\n  the PDS must `exclude` them so writes keep validating against the published\n  schema). All packages share\n  the one [lexicons-overrides/](../../../lexicons-overrides/) at the repo root.\n\n[packages/bsky/lexicons.json](../../../packages/bsky/lexicons.json) is the full\nreference (overrides + canonical directory + GitHub `http`).\n\n`include`/`exclude` globs accept `*` as a wildcard; `exclude` is applied after\n`include`.\n\n### Packages under `packages/lex/`\n\n`@atproto/lex` depends on the SDK sub-packages, so they cannot depend back on it\n(it would be circular). They generate with `@atproto/lex-builder`'s `lex-builder\nbuild --lib @atproto/lex-schema` (that is where the generated files import `l`\nfrom) instead of `lex build`, and install their manifest with `@atproto/lex-installer`'s\n`lex-installer install` instead of `lex install`:\n\n```jsonc\n\"lex:build\": \"lex-builder build --out ./src/lexicons --clear --lib @atproto/lex-schema --index-file\",\n\"lex:install\": \"lex-installer install\",\n\"postlex:install\": \"pnpm run lex:build\",\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n```\n\nSee [packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json).\nAdd `--default-export` to `lex:build` for a browser-facing client\n([packages/lex/lex-client](../../../packages/lex/lex-client/package.json)).\n\n## `lex build` flags\n\nDefaults: `--lexicons ./lexicons`, `--out ./src/lexicons`, `--no-pretty`,\n`--default-export`. yargs accepts either kebab-case or camelCase\n(`--index-file` == `--indexFile`).\n\n- `--clear` — wipe the output dir first, so deleted lexicons don't linger\n- `--override` — overwrite existing files (no-op alongside `--clear`)\n- `--indexFile` — emit `index.ts` re-exporting root namespaces (`app`, `com`, …)\n- `--include` / `--exclude <patterns…>` — filter by NSID\n- `--lib <package>` — where generated files import `l` from\n- `--importExt <ext>` — import specifier extension (`.js`; `\"\"` for extensionless)\n- `--fileExt <ext>` — emitted file extension (`.ts`)\n- `--no-pretty` — skip prettier\n- `--no-defaultExport` — drop `default` re-exports (see [Tree-shaking](#tree-shaking))\n- `--defsExport` — also expose defs under `$defs`, for when a child namespace\n  shadows a sibling definition (`com.example.foo` vs `com.example.foo.bar`)\n- `--ignore-errors` / `--ignore-invalid-lexicons` — skip bad inputs instead of failing\n\n## Using the generated tree\n\n`--indexFile` gives one entry point for all root namespaces; schemas are\naddressed by NSID dot-path:\n\n```ts\nimport { app, com, chat } from '../lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def\ncom.atproto.repo.getRecord // query/procedure schema\n```\n\nEach NSID compiles to a `<name>.defs.ts` holding the schema plus a thin\n`<name>.ts` re-exporting it (and `main` as `default`). Nothing under\n`src/lexicons/` is hand-editable — it is deleted and rewritten on every\n`--clear` build.\n\n### Tree-shaking\n\nThe generated tree is tree-shakeable, but the reference style decides how much\nsurvives. A bundler can't tell that `client.call()` only consumes `.main`, so\nnaming the namespace retains every sibling def inside it:\n\n```ts\n// Smallest — default import; preferred for browser bundles\nimport getRecord from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(getRecord, {})\n\n// Same size, but leaks the `main` identifier into your source\nimport { main as getRecord } from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(com.atproto.repo.getRecord.main, {})\n\n// Largest — namespace notation drags in sibling defs\nawait client.call(com.atproto.repo.getRecord, {})\n```\n\nServices, scripts, and tests here use namespace notation, which reads closest\nto the NSID and costs nothing server-side. Libraries and browser apps should\nprefer the default import.\n\n## After editing lexicon JSON\n\nGenerated TypeScript is not rebuilt automatically. Regenerate before building\nor testing anything downstream — a stale tree is by far the most common cause\nof \"type X is not assignable to Y\" right after a lexicon edit:\n\n```bash\npnpm codegen        # repo root: every codegen:* script in the workspace\npnpm run prebuild   # inside one package\n```\n\n`pnpm codegen` builds `@atproto/lex-builder` first via `build:tooling`. If\ncodegen output still looks stale, that build is the thing to re-run.\n\nAdding a new external NSID means listing it in the package's `lexicons.json`\n`lexicons` array (or `pnpm lex:install <nsid>`), then re-running codegen. A new\ncanonical (`com.atproto.*` / `tools.ozone.*`) schema is added at the repo root\nand picked up by each dependent package's next `lex:install`; run\n`pnpm lex:update-local` from the root to propagate a changed canonical CID into\nevery manifest.\n\n## Network-installed schemas (outside this repo)\n\nFor a package whose Lexicons are _not_ maintained alongside it, `lex install`\nfetches them from the network into `./lexicons/` and records each resolution\n(AT URI + CID) in `./lexicons.json`.\n\n```bash\nlex install app.bsky.feed.post   # add NSIDs; dependencies resolve automatically\nlex install                      # install everything the manifest lists\nlex install --update             # re-resolve all to latest\nlex install --ci                 # fail if installed files drift from manifest CIDs\nlex install --no-save <nsid>     # install without touching the manifest\n```\n\nFlags: `--manifest <path>` (default `./lexicons.json`), `--lexicons <dir>`\n(default `./lexicons`), `--update`, `--ci`, `--no-save`.\n\nCommit both the manifest and the fetched JSON — they are the input contract,\nand the recorded CIDs are what makes `--ci` meaningful. The upstream-recommended\nwiring adds `\"postinstall\": \"lex install --ci\"` to catch drift on every install\nand `\"update-lexicons\": \"lex install --update\"` as the deliberate refresh.\n\nDon't commit a second **copy** of schemas that already live in\n[lexicons/](../../../lexicons/) — it would silently diverge. In-repo, vendor by\n**symlink** instead (next section), which can't drift.\n\n## Vendoring the canonical lexicons (in-repo)\n\nConsuming [lexicons/](../../../lexicons/) through `lex install` (rather than\ncompiling it directly) is the standard setup — it makes a package's lexicon set\nan explicit, CID-locked contract. A `directory` resolver resolves each canonical\nNSID from the root; those resolutions are **symlinked** into `./lexicons/` (not\ncopied) and locked with a `file://` URI relative to the manifest, so they can't\ndiverge. [packages/pds](../../../packages/pds/package.json) is the reference:\n\n```jsonc\n// lexicons.json (committed) — roots + resolvers + CID locks\n{\n  \"version\": 2,\n  \"lexicons\": [\"com.atproto.repo.getRecord\", \"…every vendored NSID…\"],\n  \"resolvers\": [\n    {\n      \"type\": \"directory\",\n      \"path\": \"../../lexicons\",\n      \"include\": [\"com.atproto.*\", \"tools.ozone.*\"],\n    },\n  ],\n  \"resolutions\": {\n    \"com.atproto.repo.getRecord\": {\n      \"uri\": \"file://../../lexicons/com/atproto/repo/getRecord.json\",\n      \"cid\": \"…\",\n    },\n  },\n}\n```\n\n```jsonc\n// package.json — install (creating symlinks + verifying the lock), then build\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n\"lex:install\": \"lex install\",     // regenerate the manifest after adding/removing a lexicon\n\"postlex:install\": \"pnpm run lex:build\",\n\"lex:build\": \"lex build --clear --indexFile\",\n```\n\nGitignore the symlinked canonical dirs under `/lexicons` (`com/atproto`,\n`tools/ozone` — build artifacts) but **commit `lexicons.json`** (the contract),\nalong with any network- or GitHub-resolved copies. Generate/refresh the manifest with\n`pnpm run lex:install`; `--ci` then fails the build if `/lexicons` drifts from\nthe locked CIDs. Tradeoff: the `lexicons` roots list is explicit, so adding a\nnew canonical schema requires re-running `lex:install` to include it — unlike a\nplain `lex build --lexicons ../../lexicons`, which picks up every file\nimplicitly. For the full resolver vocabulary (`directory` / `http` / `repo`,\nordering, and the `lexicons-overrides` directory) see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson) above.\n\n## Removing legacy `@atproto/lex-cli` codegen\n\nThe legacy output directory depends on which generator ran — `lex gen-server`\nemits `./src/lexicon` (singular), `lex gen-api` emits `./src/client`. Both are\nfully generated and safe to delete. Leave `lexicons/` alone; it is input, not\noutput.\n\nThen drop `@atproto/lex-cli` from `devDependencies`, add a `lexicons.json`\n(resolving the canonical set from the root plus whatever external namespaces the\npackage used), replace the old `codegen:lex` with the `lex:install` / `lex:build`\nform above, and repoint `.gitignore` at `src/lexicons` plus the symlinked\ncanonical dirs under `/lexicons`. (`packages/api` is the exception — it keeps `gen-api` but now\nfeeds it a locally-installed `./lexicons`; see\n[Which `lex` binary you get](#which-lex-binary-you-get).)\n\n## Related skills\n\nOnce the package generates code: [lex-schema](../lex-schema/SKILL.md)\n(`$`-accessors, validation), [lex-data](../lex-data/SKILL.md) (values, blobs,\nCBOR, branded strings), [lex-client](../lex-client/SKILL.md) (calling out),\n[xrpc-server](../xrpc-server/SKILL.md) (defining routes), and\n[lexification-client](../lexification-client/SKILL.md) /\n[lexification-server](../lexification-server/SKILL.md) (migrating off the\nlegacy stack).\n",".agents/skills/lexification-client/SKILL.md":"---\nname: lexification-client\ndescription: >\n  Migrate AT Protocol client and consumer code off the legacy `@atproto/api`,\n  `@atproto/lexicon`, and `@atproto/xrpc` stack onto `@atproto/lex`. Use\n  whenever code that *calls out* to an XRPC service is being modernized —\n  replacing an `AtpAgent` with `Client`, `result.data` / `result.headers[…]`\n  response handling, `XRPCError` try/catch, `jsonStringToLex` / `stringifyLex`,\n  `BlobRef` `instanceof` checks, `CID` from `multiformats`,\n  `new Date().toISOString()` on AT Proto datetimes, bare `string` DIDs and\n  handles, or legacy `isX()` type guards — including when the request is\n  phrased as \"clean this up\" or \"modernize this file\" rather than \"migrate\".\n  For code that *defines* routes, use the lexification-server skill.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating client / consumer code\n\nScope: **calls out**. Anything that used `AtpAgent` or the `@atproto/lexicon` /\n`@atproto/xrpc` runtime to talk to an AT Proto service, plus the data types\nthat flow across that boundary.\n\nRoute _definitions_ (`server.add`, handler signatures, codegen removal) are the\n[lexification-server skill](../lexification-server/SKILL.md). A service package\nalmost always needs both: it defines routes **and** calls other services. If\nyou're mid-migration on a service and reach an `agent.api.…` call, you're in\nthe right file.\n\n## What changes\n\n| Old                                               | New                                                                    |\n| ------------------------------------------------- | ---------------------------------------------------------------------- |\n| `AtpAgent` from `@atproto/api`                    | `Client` from `@atproto/lex`                                           |\n| `agent.api.<ns>.<method>(params, { headers })`    | `client.call(<schema>, params, opts)` or `client.xrpc(<schema>, opts)` |\n| `result.data`                                     | `result.body`                                                          |\n| `result.headers['content-language']`              | `result.headers.get('content-language')` (standard `Headers`)          |\n| `XRPCError` from `@atproto/xrpc`                  | `XrpcError` from `@atproto/lex`                                        |\n| try/catch + `instanceof XRPCError`                | `xrpcSafe()` / `client.xrpcSafe()` discriminated result                |\n| `HeadersMap` from `@atproto/xrpc`                 | `Headers as HeadersMap` from `@atproto/xrpc-server`                    |\n| `jsonStringToLex` / `stringifyLex`                | `lexParse` / `lexStringify`                                            |\n| `BlobRef` class from `@atproto/lexicon`           | `BlobRef` interface from `@atproto/lex-data` (or `@atproto/lex`)       |\n| `CID` from `multiformats/cid`                     | `Cid` / `parseCid` from `@atproto/lex`                                 |\n| `new Date().toISOString()` for AT Proto datetimes | `currentDatetimeString()` / `toDatetimeString()`                       |\n| `isX(v)` from `src/lexicon/types/…`               | `<ns>.<def>.$matches(v)` / `.$isTypeOf(v)`                             |\n\n## XRPC calls\n\nThree shapes, in rough order of preference — pick the least machinery that\ncovers what the call site actually uses.\n\n### `call()` — you only want the body\n\nErrors propagate as exceptions. Params (query) or body (procedure) go in the\nsecond argument; everything else in the third.\n\n```ts\nreturn ctx.suggestionsClient.call(\n  app.bsky.unspecced.getSuggestedUsersSkeleton,\n  { limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined },\n  { headers: params.headers },\n)\n```\n\n### `xrpc()` — you need status or headers too\n\n```diff\n- const res = await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton(\n-   { viewer: params.hydrateCtx.viewer, relativeToDid },\n-   { headers: params.headers },\n- )\n- return {\n-   suggestedDids: res.data.actors.map((a) => a.did),\n-   resHeaders: res.headers,\n- }\n+ const res = await ctx.suggestionsClient.xrpc(\n+   app.bsky.unspecced.getSuggestionsSkeleton,\n+   {\n+     params: { viewer: params.hydrateCtx.viewer, relativeToDid },\n+     headers: params.headers,\n+   },\n+ )\n+ const contentLang = res.headers.get('content-language')\n+ return {\n+   suggestedDids: res.body.actors.map((a) => a.did),\n+   resHeaders: contentLang ? { 'content-language': contentLang } : undefined,\n+ }\n```\n\nEverything is one options object: query params under `params`, procedure\nbodies under `body` (not `input`), plus `headers`, `signal`, `service`,\n`labelers`, `maxRetries`, and the validation flags.\n\nResponse headers are a standard `Headers` instance. If the value flows into\nsomething typed `HeadersMap` (`Record<string, string>`), pull the specific\nheaders out with `.get()` as above; if the consumer takes `Headers`, pass it\nthrough unchanged.\n\n### `xrpcSafe()` — errors are part of the contract\n\nReturns a discriminated result instead of throwing, and the failure is typed\nagainst the method schema. Use it where the call site already had a `catch`\nthat inspected the error rather than just rethrowing.\n\nNarrow on the concrete failure class when the branches map to different\ndownstream responses (see `packages/bsky/src/api/app/bsky/feed/getFeed.ts`):\n\n```ts\nimport {\n  XrpcInvalidResponseError,\n  XrpcResponseError,\n  xrpcSafe,\n} from '@atproto/lex'\n\nconst result = await xrpcSafe(fgEndpoint, app.bsky.feed.getFeedSkeleton, {\n  strictResponseProcessing: false,\n  signal: AbortSignal.timeout(10_000),\n  headers,\n  params: { feed, limit, cursor },\n})\n\nif (!result.success) {\n  const cause = result.reason\n\n  // Structurally valid XRPC error (4xx/5xx) — pass it through\n  if (cause instanceof XrpcResponseError) {\n    const { status, body } = cause.toDownstreamError()\n    throw new XRPCError(status, body.message, body.error, { cause })\n  }\n\n  // Response didn't match the schema\n  if (cause instanceof XrpcInvalidResponseError) {\n    throw new UpstreamFailureError(\n      'feed provided an invalid response',\n      'InvalidFeedResponse',\n      { cause },\n    )\n  }\n\n  // Typically a network error\n  throw new UpstreamFailureError('feed unavailable', undefined, { cause })\n}\n\nconst { feed: feedSkele, cursor } = result.body // typed\n```\n\n`toDownstreamError()` is what replaces hand-mapping upstream status codes: it\nremaps 500 → 502 and strips hop-by-hop headers. It does **not** redact — when\nthe upstream payload is a structurally valid XRPC error, its `error` / `message`\nare passed through verbatim for transparency. Sanitize them yourself before\nre-throwing if the upstream may leak internal detail.\n\nWhen you only care about one schema-declared error code, skip the class checks:\n\n```ts\nconst res = await xrpcSafe(pds, com.atproto.sync.getLatestCommit, {\n  params: { did },\n})\nif (res.success) return true\nif (res.error === 'RepoNotFound') return false\nthrow res.reason\n```\n\n`result.matchesSchemaErrors()` narrows `result.error` to the codes the method\ndeclares, for when you want the compiler to check the code strings.\n\nThe old `ResponseType` enum comparisons have no replacement and don't need one:\n`XrpcInvalidResponseError` covers `ResponseType.InvalidResponse`, and\n`XrpcInternalError` / `XrpcFetchError` cover `ResponseType.Unknown`.\n\nRetries are built in (`maxRetries`, default `0`), so delete any hand-rolled\nretry loop wrapped around the call. See [lex-client](../lex-client/SKILL.md)\nfor the full error hierarchy and client API.\n\n## Agent → Client\n\n```diff\n- import { AtpAgent } from '@atproto/api'\n+ import { Client } from '@atproto/lex'\n\n- searchAgent: AtpAgent | undefined\n+ searchClient: Client | undefined\n```\n\n`Client` takes **two** arguments: the agent (a service URL, an `AgentConfig`,\nor a session), then per-client options. Static headers belong in the agent\nconfig rather than an imperative `setHeader` after construction — one frozen\nconfig then describes the whole client (see `packages/bsky/src/index.ts`):\n\n```diff\n- const myServiceAgent = config.serviceUrl\n-   ? new AtpAgent({ service: config.serviceUrl })\n-   : undefined\n- if (myServiceAgent && config.serviceApiKey) {\n-   myServiceAgent.api.setHeader('authorization', `Bearer ${config.serviceApiKey}`)\n- }\n+ const myServiceClient = config.serviceUrl\n+   ? new Client(\n+       {\n+         service: config.serviceUrl,\n+         headers: config.serviceApiKey\n+           ? { authorization: `Bearer ${config.serviceApiKey}` }\n+           : undefined,\n+       },\n+       {\n+         // Trust internal services to send us well-formed responses\n+         strictResponseProcessing: false,\n+         validateResponse: config.debugMode,\n+       },\n+     )\n+   : undefined\n```\n\nThat second argument is worth setting for internal service-to-service clients:\n`Client` validates responses by default, which is stricter than `AtpAgent` was,\nso a migrated call can start failing on data the old code happily accepted.\n\n## Errors\n\n```diff\n- import { XRPCError } from '@atproto/xrpc'\n+ import { XrpcError } from '@atproto/lex'\n```\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for **throwing** errors out of\nhandlers — that one is unrelated and stays. A file can legitimately import\nboth (getFeed.ts above does): `XrpcError` describes a failed call out,\n`XRPCError` produces the response going back down.\n\n## Headers map type\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import type { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAlias it — the unaliased `Headers` shadows the global `Headers` type, which the\nsame file usually also needs.\n\n## Data utilities\n\n### JSON ↔ Lex\n\n```diff\n- import { jsonStringToLex } from '@atproto/lexicon'\n+ import { lexParse } from '@atproto/lex'\n\n- const parsed = jsonStringToLex(payload.toString('utf8')) as SubjectActivitySubscription\n+ const parsed = lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(\n+   payload.toString('utf8'),\n+ )\n```\n\n`lexParse` takes a type parameter, so the `as` cast goes away. `stringifyLex` →\n`lexStringify`. See [lex-data](../lex-data/SKILL.md) for `jsonToLex`,\n`lexToJson`, `lexParseJsonBytes`, `parseLexLink`, `parseLexBytes`.\n\n### Datetime strings\n\n```diff\n- createdAt: new Date().toISOString(),\n+ createdAt: currentDatetimeString(),\n\n- indexedAt: someDate.toISOString(),\n+ indexedAt: toDatetimeString(someDate),\n```\n\nBoth come from `@atproto/lex` and return the branded `DatetimeString`, so a\nvalue that reaches a lexicon field is checked at the type level rather than at\nvalidation time.\n\n### CIDs\n\n```diff\n- import { CID } from 'multiformats/cid'\n+ import { type Cid, parseCid } from '@atproto/lex'\n```\n\n`Cid` is an interface. Drop `multiformats` from the package's dependencies once\nno imports remain.\n\n### BlobRef\n\n`BlobRef` is now an interface (a union of `TypedBlobRef` and `LegacyBlobRef`),\nso `instanceof` no longer works — and the hand-written fallbacks that used to\npaper over the two JSON shapes are exactly what the helpers replace:\n\n```diff\n- import { BlobRef } from '@atproto/lexicon'\n+ import { type BlobRef, getBlobCidString } from '@atproto/lex-data'\n\n- export const cidFromBlobJson = (json: BlobRef) => {\n-   if (json instanceof BlobRef) return json.ref.toString()\n-   if (json['$type'] === 'blob') return (json['ref']?.['$link'] ?? '') as string\n-   return (json['cid'] ?? '') as string\n- }\n+ export const cidFromBlobJson = (json: BlobRef): string => getBlobCidString(json)\n```\n\n```diff\n- if (value instanceof BlobRef) { ... }\n+ if (isBlobRef(value)) { ... }\n```\n\n`isBlobRef` validates the CID strictly by default; pass `{ strict: false }` for\nlegacy data. See [lex-data](../lex-data/SKILL.md) for the\n`TypedBlobRef` / `LegacyBlobRef` split.\n\n## Branded types at boundaries\n\nApply `DidString`, `HandleString`, `AtUriString`, `DatetimeString`, `Cid` at\nsignatures, interface fields, and DB schema types — that's where they stop bad\nvalues propagating. Prefer importing them from `@atproto/lex` over\n`@atproto/syntax`.\n\n```diff\n- did: string\n+ did: DidString\n\n- iss: string\n+ iss: DidString | `${DidString}#${string}`\n```\n\nPrefix checks become guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nData arriving from protobuf, the data plane, or Kysely is plain `string`. Cast\nonce at that entry point rather than asserting at every later use:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n```\n\n## Type guards\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { ... }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { ... }\n```\n\n`$isTypeOf` is the behavioral equivalent of a legacy `isX()` guard, so it is the\ndefault when replacing one — this is what the repo migrated to (see\n`packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts`). It checks only\nthe `$type` tag, which is all that matters on an already-validated union, and it\ndoubles as a predicate:\n\n```diff\n- const pref = prefs.find((p) => p.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ const pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` instead only where the data is genuinely untrusted and a\n`$type` tag alone isn't enough of a guarantee — it validates the whole value, so\nit costs more. Treat that as a deliberate upgrade at an entry point, not the\ndefault swap for an `isX()` call.\n\nSee [lex-schema](../lex-schema/SKILL.md) for the full `$`-accessor cheat sheet.\n\n## Tests\n\nTest migration is partial and lags the source migration. The `pds`, `bsky` and\n`ozone` suites mostly still drive `AtpAgent` from `@atproto/api`, and `dev-env`\nexposes both `getAgent(): AtpAgent` and `getClient(): Client` for exactly that\nreason.\n\nA suite still on `AtpAgent` hydrates blobs into **legacy `BlobRef` class\ninstances**, which only `@atproto/lexicon`'s `lexToJson` knows how to flatten.\nSwapping a test helper to `@atproto/lex`'s `lexToJson` leaves those instances\nuntouched and leaks their internal `original` field into snapshots — which reads\nlike a wire-format regression in the service under test. Keep the legacy\nconverter until the suite itself moves to `Client`.\n\nDefault to leaving passing tests alone: during a source migration their value\nis being an unchanged runtime regression check. Two things force a change:\n\n- **A test imports a legacy path you deleted** (`src/lexicon/…`). Point it at\n  the `@atproto/api` equivalent (`ComAtprotoAdminDefs`, `ids`, `$Typed`) — the\n  smallest edit that keeps the test running.\n- **You are deliberately migrating that test file.** Then it moves wholesale:\n  `network.pds.getClient()`, schemas from `src/lexicons/index.js`, `res.body`,\n  and helpers like `getBlobCidString` from `@atproto/lex-data`.\n  `packages/pds/tests/file-uploads.test.ts` is a migrated example.\n\nAvoid half-migrating a file — a suite mixing `agent.api.…` and `client.call(…)`\nfor the same operation is harder to read than either end state.\n\n## Import source summary\n\n| Before                                            | After                                            |\n| ------------------------------------------------- | ------------------------------------------------ |\n| `@atproto/api` (`AtpAgent`)                       | `@atproto/lex` (`Client`)                        |\n| `@atproto/lexicon` (`jsonStringToLex`, `BlobRef`) | `@atproto/lex` (`lexParse`, `BlobRef`)           |\n| `@atproto/lexicon` (`stringifyLex`)               | `@atproto/lex` (`lexStringify`)                  |\n| `@atproto/xrpc` (`XRPCError`)                     | `@atproto/lex` (`XrpcError`, `xrpcSafe`)         |\n| `@atproto/xrpc` (`HeadersMap`)                    | `@atproto/xrpc-server` (`Headers as HeadersMap`) |\n| `multiformats/cid` (`CID`)                        | `@atproto/lex` (`Cid`, `parseCid`)               |\n| `@atproto/syntax` (`DidString`, etc.)             | `@atproto/lex` (prefer this)                     |\n\nDropped imports usually mean dropped `package.json` dependencies\n(`multiformats`, `@atproto/lexicon`, `@atproto/xrpc`), and every package\ntouched needs a changeset entry.\n\n## Related skills\n\n[lex-client](../lex-client/SKILL.md) for the full `Client` API you're migrating\nonto, [lex-setup](../lex-setup/SKILL.md) for codegen and dependency wiring, and\n[lexification-server](../lexification-server/SKILL.md) for the route-definition\nhalf — service packages need both.\n",".agents/skills/lexification-server/SKILL.md":"---\nname: lexification-server\ndescription: >\n  Migrate a service package off the legacy server stack — `lex gen-server`\n  codegen, a committed `src/lexicon/` tree, `server.<ns>.<method>()` route\n  chains, `@atproto/api` inside service code — onto `@atproto/lex` and\n  `@atproto/xrpc-server`. Use when asked to lexify, modernize, or migrate a\n  server package; when a route file imports `Server` or `createServer` from\n  `../lexicon/index.js`; when replacing `ids.*` constants, generated `isX()`\n  guards, `server.xrpc.router`, or `QueryParams`/`OutputSchema` type imports;\n  and when a package still runs `lex gen-server` in its `codegen:lex` script.\n  For the calls a service makes *out* to other services, use\n  lexification-client. For writing new routes on an already-migrated package,\n  use xrpc-server.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating a service package\n\nMoves a package from `@atproto/lex-cli`'s `lex gen-server` output to\n`@atproto/lex` schemas plus `@atproto/xrpc-server` routing.\n\nEvery service in this repo — `packages/pds`, `packages/bsky`, `packages/ozone` —\nis already migrated, so this skill now applies to packages outside the repo, or\nto a stray route chain left behind. **Read the migrated services before\ninventing a pattern.**\n\n## Which skill applies\n\n| Situation                                                    | Skill                                                  |\n| ------------------------------------------------------------ | ------------------------------------------------------ |\n| Package has `src/lexicon/` (singular) and route chains       | **this skill**                                         |\n| Adding a route to a package already on `server.add()`        | [xrpc-server](../xrpc-server/SKILL.md)                 |\n| The same package's outbound `AtpAgent` / `agent.api.…` calls | [lexification-client](../lexification-client/SKILL.md) |\n\nA service migration almost always needs this skill _and_\nlexification-client: one package both serves routes and calls other services.\nDo the server half first — it forces `src/lexicons/` into existence, which the\nclient half then imports from.\n\n## Guiding principles\n\n- **Type-level changes over runtime changes.** Working runtime logic stays;\n  what changes is where types and constants come from. A migration diff that\n  alters behavior is a bug in the migration.\n- **Migrate one namespace of route files at a time**, building in between.\n  A route file is self-contained, so a broken one doesn't block the rest.\n- **Leave test call sites on `AtpAgent`.** Tests double as the regression\n  check that the migration didn't change behavior — swapping the client they\n  use destroys that signal. Tests that import _types or constants_ from the\n  deleted `src/lexicon/` do have to move (details below).\n\n## Order of work\n\n1. **Project config** — swap codegen, deps, gitignore, delete `src/lexicon/`.\n   → [references/project-config.md](references/project-config.md)\n2. **Server bootstrap** — `createServer`, `Server` type, Express mount.\n3. **Route files** — `server.<ns>.<method>(…)` → `server.add(schema, …)`.\n   → [references/routes.md](references/routes.md)\n4. **Constants and guards** — `ids.*`, `isX()`, token exports, `$build`.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n5. **Outbound calls** — [lexification-client](../lexification-client/SKILL.md).\n6. **Branded types** at function/interface/DB boundaries.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n\nSteps 1–2 are a single non-splittable commit (the package won't compile\nin between). Steps 3–6 are incremental.\n\n## Server bootstrap\n\nThe only structural change in the whole migration. `createServer` and `Server`\nstop coming from generated code:\n\n```diff\n- import { createServer } from './lexicon/index.js'\n+ import { createServer } from '@atproto/xrpc-server'\n\n- let server = createServer({\n+ const server = createServer([], {\n    validateResponse: config.debugMode,\n    payload: { /* … */ },\n  })\n\n- app.use(server.xrpc.router)\n+ app.use(server.router)\n```\n\nThe new first argument is an array of legacy `LexiconDoc`s for routes not\nbacked by a generated schema; schema-based setups pass `[]`. Option names\n(`validateResponse`, `payload`, `catchall`, `rateLimits`, `errorParser`) are\nunchanged — see [packages/pds/src/index.ts](../../../packages/pds/src/index.ts).\n\nIn route files, `Server` comes from the same package:\n\n```diff\n- import { Server } from '../../../../lexicon/index.js'\n+ import type { Server } from '@atproto/xrpc-server'\n```\n\n## Import mapping\n\n| Before                                                 | After                                                                |\n| ------------------------------------------------------ | -------------------------------------------------------------------- |\n| `../lexicon` (`Server`, `createServer`)                | `@atproto/xrpc-server`                                               |\n| `../lexicon/lexicons` (`ids`)                          | `../lexicons/index.js` → `.$type` / `.$lxm`                          |\n| `../lexicon/types/…` (record & def types)              | `../lexicons/index.js` → `.Main`, `.ViewRecord`, …                   |\n| `../lexicon/types/…` (`QueryParams`, `OutputSchema`)   | `../lexicons/index.js` → `.$Params`, `.$OutputBody`                  |\n| `../lexicon/types/…` (`isX`, `validateX`)              | `../lexicons/index.js` → `.$isTypeOf`, `.$matches`, `.$safeValidate` |\n| `../lexicon/util` (`$Typed`, `Un$Typed`)               | `@atproto/lex`                                                       |\n| `@atproto/api` (`AtpAgent`)                            | `@atproto/lex` (`Client`)                                            |\n| `@atproto/lexicon` (`jsonStringToLex`, `stringifyLex`) | `@atproto/lex` (`lexParse`, `lexStringify`)                          |\n| `@atproto/lexicon` (`BlobRef`)                         | `@atproto/lex` (`BlobRef`)                                           |\n| `@atproto/xrpc` (`HeadersMap`)                         | `@atproto/xrpc-server` (`Headers`, usually aliased)                  |\n| `@atproto/xrpc` (`XRPCError`, thrown by a _caller_)    | `@atproto/lex` (`XrpcError`)                                         |\n| `multiformats/cid` (`CID`)                             | `@atproto/lex` (`Cid`, `parseCid`)                                   |\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for errors handlers **throw**.\nThe casing distinguishes them: `XRPCError` server-side (concrete, thrown),\n`XrpcError` client-side (abstract, caught). Both can appear in one file.\n\n`@atproto/lex` re-exports `lex-client`, `lex-schema`, `lex-data`, and\n`lex-json`, so `Cid`, `BlobRef`, `lexParse`, and `$Typed` all resolve from it.\n`@atproto/lex-cbor` is **not** re-exported. Importing from either the umbrella\nor the sub-package is fine; match whatever the file already does.\n\n## Pitfalls\n\n- **`$type` vs `$lxm` vs `$nsid`.** `$type` is the record/object type string\n  (`app.bsky.feed.post.$type` → `'app.bsky.feed.post'`), used for collections\n  and union tags. `$lxm` is the method id, used in auth and proxy checks.\n  `$nsid` is the raw NSID, needed for `defs` documents that have no `main`.\n  They can hold identical strings, so a wrong one type-checks and only fails\n  at runtime.\n- **`$matches` vs `$isTypeOf`.** `$isTypeOf` only reads the `$type` tag;\n  `$matches` validates the whole value. Substituting `$isTypeOf` where the\n  data is unvalidated silently accepts malformed records. See\n  [references/types-and-guards.md](references/types-and-guards.md).\n- **`encoding` widening** affects only the bare-handler form of `server.add`,\n  not the object form. Don't sprinkle `as const` everywhere — see\n  [references/routes.md](references/routes.md).\n- **Branded strings arriving from outside TypeScript's view** — protobuf,\n  Kysely rows, `JSON.parse` — are plain `string`. Cast once at the boundary\n  (`as DidString`), never deep in the call graph.\n- **Generated `src/lexicons/` is gitignored.** It won't exist until\n  `pnpm run codegen` (or a build) runs. \"Cannot find module '../lexicons/index.js'\"\n  means codegen hasn't run, not that the import is wrong.\n\n## Tests\n\nTest _call sites_ keep using `AtpAgent`, deliberately (see principles above).\n`dev-env` exposes both `getAgent(): AtpAgent` and `getClient(): Client`, so a\nnew-style client is available when a test genuinely needs one.\n\nWhat must move is anything a test imported from the now-deleted\n`src/lexicon/`. Two valid destinations, both already used in migrated\npackages:\n\n```diff\n- import { ids } from '../../src/lexicon/lexicons'\n- import { isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'\n+ import { com } from '../../src/lexicons/index.js'      // generated schemas\n+ import { $Typed, ComAtprotoAdminDefs, ids } from '@atproto/api'  // legacy equivalents\n```\n\nPrefer `../../src/lexicons/index.js` for new code; `@atproto/api` is the\nlower-churn option when the surrounding test is otherwise untouched. Do not\nadd `@atproto/api` back to a package's dependencies just for this — it is a\ndevDependency in `pds` and only a runtime dependency in `bsky` because of\n`getAgeAssuranceRegionConfig`, unrelated to tests.\n\nBefore writing or restructuring tests, use the\n[testing skill](../testing/SKILL.md) — `pds` and `ozone` are jest, `bsky` is\nvitest.\n\n## Related skills\n\n[xrpc-server](../xrpc-server/SKILL.md) documents the target route API in full\nand is the better reference once a package is migrated.\n[lex-setup](../lex-setup/SKILL.md) owns codegen wiring,\n[lex-schema](../lex-schema/SKILL.md) the `$`-accessors, and\n[lex-data](../lex-data/SKILL.md) branded strings, CIDs, and blobs.\n",".agents/skills/lexification-server/references/project-config.md":"# Project configuration\n\nRead this for step 1 of the migration: replacing `lex gen-server` codegen with\n`lex build`, fixing dependencies, and deleting the legacy generated tree.\n\n`packages/ozone/package.json` is the current \"before\"; `packages/pds` and\n`packages/bsky` are the \"after\".\n\n## Scripts\n\nBefore (ozone — note the hand-maintained glob list and the staleness check):\n\n```jsonc\n\"codegen:lex\": \"lex gen-server --yes ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/*\",\n\"prebuild\": \"[ -f ./src/lexicon/index.ts ] && [ -z \\\"$(find ../../lexicons -newer ./src/lexicon/index.ts -type f -print -quit)\\\" ] || pnpm run codegen:lex\",\n```\n\nAfter:\n\n```jsonc\n\"codegen:lex\": \"lex build --clear --indexFile --lexicons ../../lexicons\",\n\"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n```\n\nTwo things fall out of this, both deliberate:\n\n- No NSID list. `lex build` compiles the whole `lexicons/` tree, so adding a\n  lexicon needs no `package.json` edit. (Small libraries use `--include` to\n  trim output; a service that serves most of a namespace should not.)\n- No staleness guard. `--clear` regenerates unconditionally and the glob\n  `prebuild` picks up every `codegen:*` script the package declares, so a\n  package that also generates protobuf (`bsky`) or templates (`pds`) needs no\n  change to that line. Keep the name `codegen:lex` — root `pnpm codegen` runs\n  `/^(codegen:.+)$/` across the workspace, and a generator hidden inside\n  `prebuild` would be skipped by it.\n\nFull flag reference: [lex-setup skill](../../lex-setup/SKILL.md).\n\n## Dependencies\n\nAdd to `dependencies`:\n\n- `@atproto/lex`\n- `@atproto/xrpc-server` (already present in every service — verify, don't add\n  blindly)\n\nRemove:\n\n- `@atproto/lexicon` — `dependencies`\n- `@atproto/xrpc` — `dependencies`\n- `multiformats` — `dependencies`; CIDs come from `@atproto/lex-data` via\n  `@atproto/lex`\n- `@atproto/lex-cli` — `devDependencies`\n\nAdd the `@atproto/lex-*` sub-packages only when a file imports them directly.\n`pds` lists `lex-cbor`, `lex-data`, and `lex-json` in `dependencies` and\n`lex-document` in `devDependencies` for exactly that reason; `bsky` needs none\nof them and imports everything through the umbrella.\n\nKeep `@atproto/api` only where it is genuinely still used. It stays a\ndevDependency of `pds` (tests) and a runtime dependency of `bsky` (two source\nfiles import `getAgeAssuranceRegionConfig` and age-assurance helpers from it).\n\nEvery package touched needs a `.changeset/` entry — `minor` for a package\nwhose public API changes, `patch` otherwise.\n\n## Gitignore\n\nGenerated output is a build artifact:\n\n```\n# @atproto/lex\nsrc/lexicons\n```\n\nReplaces the `# @atproto/lex-cli` / `src/lexicon` stanza. Note singular →\nplural: `src/lexicon` was the legacy path, `src/lexicons` is the new one. Both\nlines are unanchored patterns, not `/src/lexicons/`.\n\n## Deleting the legacy tree\n\n```sh\nrm -rf ./src/lexicon\n```\n\nSafe because it was gitignored and regenerated. Verify nothing still imports\nit before deleting — in ozone that is ~76 source files plus ~47 test files, so\nexpect the package not to compile until step 3 is done.\n\nDo **not** touch the repo-root `lexicons/` directory. It is canonical\ncommitted source shared by every package and the interop fixtures.\n\n## What does not apply in this monorepo\n\n`lex install`, `manifest.json` / `lexicons.json`, and a `postinstall` hook are\nreal `@atproto/lex` features for consuming schemas published over the network.\nNo package here uses them, because the schemas are committed at the repo root.\nDo not introduce a manifest during a migration.\n",".agents/skills/lexification-server/references/routes.md":"# Migrating route files\n\nRead this for step 3: converting `server.<ns>.<method>(…)` registrations to\n`server.add(schema, …)`, and moving parameter/output types off\n`../lexicon/types/…`.\n\nFor the target API in full — auth verifiers, rate limits, pipethrough, error\nclasses — see the [xrpc-server skill](../../xrpc-server/SKILL.md). This file\ncovers only what the _migration_ changes.\n\n## Registration\n\nThe namespace chain becomes a schema argument. Everything inside the config\nobject is unchanged:\n\n```diff\n- server.app.bsky.feed.getAuthorFeed({\n+ server.add(app.bsky.feed.getAuthorFeed, {\n    auth: ctx.authVerifier.optionalStandardOrRole,\n    handler: async ({ params, auth, req }) => { /* unchanged */ },\n  })\n```\n\nThe bare-handler form drops the wrapper object:\n\n```diff\n- server.com.atproto.identity.resolveHandle(async ({ req, params }) => {\n+ server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n```\n\nThe schema always comes from the generated tree, imported at the top of the\nfile alongside `Server`:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../../lexicons/index.js'\n```\n\nMigrated code overwhelmingly uses the object form (~187 registrations in\n`pds` + `bsky`, versus ~8 bare-handler). Preserve whichever form the file\nalready had; there is no reason to convert between them during a migration.\n\n## `encoding` and literal widening\n\nThe form of the route — bare handler vs `{ handler }` object — is not what\ndecides this. What matters is whether the returned literal is still\n_contextually typed_ by `server.add()`. Returning it directly from a handler\nthat declares at least one parameter is, in both forms, so `as const` is\nunnecessary. Contextual typing is lost when the response is built into an\nintermediate variable first, or when the handler declares no parameters at all\n(`async () => ({ … })`); then `encoding` widens to `string` and the overload\nfails with `TS2769: No overload matches this call`.\n\n```ts\n// contextually typed — `as const` unnecessary in either form\nserver.add(app.bsky.graph.getRelationships, {\n  handler: async ({ params }) => {\n    return { encoding: 'application/json', body: { actor, relationships } }\n  },\n})\n\n// also contextually typed; the `as const` here is redundant but harmless,\n// and is what the real resolveHandle route still carries\nserver.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n  return { encoding: 'application/json' as const, body: { did } }\n})\n```\n\nMany existing routes carry `as const` where it is not strictly needed; it is\nharmless, so leave it alone rather than churning call sites. Full rules live in\nthe [xrpc-server skill](../../xrpc-server/SKILL.md).\n\n`satisfies …$Output` on the returned envelope also pins the literal and works\nin either form, but it is verbose and the repo does not use it. If a return\nneeds an explicit contract, the one real precedent annotates the _body_:\n\n```ts\nbody: { /* … */ } satisfies app.bsky.unspecced.getConfig.$OutputBody,\n```\n\n(`$Output` is the whole `{ encoding, body }` envelope; `$OutputBody` is just\nthe body. Mixing them up produces a confusing excess-property error.)\n\n## Type-only references\n\n```diff\n- import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed'\n+ import { app } from '../../../../lexicons/index.js'\n- type Params = QueryParams\n+ type Params = app.bsky.feed.getAuthorFeed.$Params\n```\n\n| Legacy generated type                | Accessor                                    |\n| ------------------------------------ | ------------------------------------------- |\n| `QueryParams` / `InputSchema` params | `$Params`                                   |\n| `OutputSchema`                       | `$OutputBody`                               |\n| whole handler output                 | `$Output`                                   |\n| `InputSchema` (procedures)           | `$Input`                                    |\n| subscription message union           | `$Message`                                  |\n| record / main object                 | `.Main`                                     |\n| sub-definitions                      | their own name (`.ReplyRef`, `.ViewRecord`) |\n\nBecause the namespace object carries both values and types, one\n`import { app } from '../lexicons/index.js'` replaces every per-method type\nimport in the file. Use `import type` when the file needs only types.\n\n## `$lxm` in auth and proxy code\n\nMethod-id strings that used to come from `ids`:\n\n```diff\n- const aud = computeProxyTo(ctx, req, ids.AppBskyActorGetPreferences)\n+ const aud = computeProxyTo(ctx, req, app.bsky.actor.getPreferences.$lxm)\n```\n\nInside a handler where only the schema object is in scope, `l.getMain(ns)`\nreaches the underlying method definition (`.output.schema`, `.$type`) — used by\n`pds`'s read-after-write helpers and `bsky`'s data-plane record routes.\n\n## Subscriptions\n\nRegister the same way; the handler is an async generator and messages are\nbuilt with `$build`:\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    yield com.atproto.sync.subscribeRepos.commit.$build({ seq, time, ...evt })\n  },\n)\n```\n\n`signal` is an `AbortSignal` on the subscription context — pass it to whatever\nproduces events so client disconnects unwind the generator. See\n[packages/pds/src/api/com/atproto/sync/subscribeRepos.ts](../../../../packages/pds/src/api/com/atproto/sync/subscribeRepos.ts).\n\n## Errors thrown by handlers\n\nNothing to do. `InvalidRequestError`, `AuthRequiredError`, `ForbiddenError`,\n`InternalServerError`, `UpstreamFailureError`, `UpstreamTimeoutError`,\n`NotEnoughResourcesError`, and `MethodNotImplementedError` already come from\n`@atproto/xrpc-server` in unmigrated packages — the legacy generated tree never\nre-exported them. Leave those imports alone.\n\nWhat does change is errors the service _catches_ from its own outbound calls —\nthose move from `@atproto/xrpc`'s `XRPCError` to `XrpcError` from\n`@atproto/lex`. `XrpcError` is abstract: catch and narrow it, never construct\nit. That half belongs to\n[lexification-client](../../lexification-client/SKILL.md).\n\n## Header maps\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAliased because `Headers` collides with the DOM global. The types are not\nidentical: legacy `HeadersMap` was `Record<string, string | undefined>`, the\nnew `Headers` is `Record<string, string>`. Code that relied on assigning\n`undefined` to clear a header needs the key omitted instead.\n",".agents/skills/lexification-server/references/types-and-guards.md":"# Constants, guards, and branded types\n\nRead this for steps 4 and 6: replacing `ids.*`, the generated `isX()` guards,\ntoken constants, and hand-written `$type` literals; then tightening string\ntypes at boundaries.\n\n## `ids.*` → `$type` / `$lxm`\n\nThe `ids` object is gone. Which accessor replaces it depends on what the\nstring is _for_:\n\n```diff\n- import { ids } from '../../../lexicon/lexicons'\n- if (uri.collection === ids.AppBskyGraphList) {\n+ import { app } from '../../../lexicons/index.js'\n+ if (uri.collection === app.bsky.graph.list.$type) {\n```\n\n```diff\n- method === ids.AppBskyFeedGetFeedSkeleton\n+ method === app.bsky.feed.getFeedSkeleton.$lxm\n```\n\n- `$type` — record and object type strings (collections, union tags).\n- `$lxm` — XRPC method ids (auth scopes, proxy targets).\n- `$nsid` — the raw NSID; the fallback for `defs` documents that define no\n  `main` type and therefore have no `$type`.\n\nUse the accessor, not a string literal. A literal is untracked when an NSID is\nrenamed and invisible to the tree-shaking that keeps generated output small.\n\n## `isX()` → `$isTypeOf` or `$matches`\n\nLegacy codegen emitted one `isX()` per definition that checked only the\n`$type` field. The new tree splits that into two accessors, and choosing\ncorrectly matters:\n\n- **`$isTypeOf`** — reads the `$type` tag only. A type predicate, so TS\n  narrows automatically inside `.find()` / `.filter()` / `if`. Correct for\n  discriminating a union whose members were already validated (schema output,\n  hydrated views, anything that came through `$parse`).\n- **`$matches`** — validates the entire value against the schema. Correct\n  when the data's shape is not yet guaranteed: raw records off the firehose,\n  values out of the datastore, third-party input.\n\n`$isTypeOf` is the direct behavioral equivalent of the old `isX()`, and it is\nwhat the real migration used:\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { … }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { … }\n```\n\n```diff\n- repost: isSkeletonReasonRepost(item.reason) ? … : undefined,\n+ repost: app.bsky.feed.defs.skeletonReasonRepost.$isTypeOf(item.reason) ? … : undefined,\n```\n\nHand-written `$type` comparisons collapse the same way:\n\n```diff\n- prefs.find((pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` deliberately, at the point where untrusted data enters —\nin `bsky` that is the record indexing plugins and hydration, in `pds` it is\nre-reading a stored record. Both packages use it a dozen times total against\n~50 uses of `$isTypeOf`.\n\nValidation that returns a result rather than a boolean is `$safeValidate` (or\n`$validate` to throw). See [lex-schema](../../lex-schema/SKILL.md).\n\n## Token values\n\n```diff\n- import { CURATELIST, MODLIST } from '../../../../lexicon/types/app/bsky/graph/defs'\n+ import { app } from '../../../../lexicons/index.js'\n+ const CURATELIST = app.bsky.graph.defs.curatelist.$token\n+ const MODLIST = app.bsky.graph.defs.modlist.$token\n```\n\nUse `$token`, per [STYLE_GUIDE.md](../../../../STYLE_GUIDE.md). It reads as one\nof the `$`-prefixed schema constants (`$type`, `$lxm`, `$nsid`) rather than as a\nfield on the value. Some older call sites use `.value`, which resolves to\nthe same string — replace them if you are editing that file.\n\n## `$build()` for `$type`-carrying values\n\nObject literals that hand-wrote a `$type` field:\n\n```diff\n- return {\n-   $type: 'app.bsky.graph.defs#relationship',\n-   did,\n-   following: subject.following,\n- }\n+ return app.bsky.graph.defs.relationship.$build({\n+   did,\n+   following: subject.following,\n+ })\n```\n\n`$build` fills in `$type` and types the result, so a renamed definition\nbecomes a compile error instead of a silently wrong tag. It is also how\nsubscription messages are emitted (see\n[routes.md](routes.md#subscriptions)).\n\nValues that carry no `$type` need no builder — a type annotation is enough,\nand that is what most def usages become:\n\n```ts\ntype CodeDetail = com.atproto.server.defs.InviteCode\n```\n\n## Centralized type-alias files\n\nA package with a `types.ts` re-exporting from `src/lexicon/types/…` repoints\nit at the generated tree. `packages/bsky/src/views/types.ts` is the live\nexample; copy its conventions rather than inventing names:\n\n```ts\nimport { app, com } from '../lexicons/index.js'\n\nexport type ProfileRecord = app.bsky.actor.profile.Main\nexport const isProfileRecordType = app.bsky.actor.profile.$isTypeOf\n\nexport type StrongRef = com.atproto.repo.strongRef.Main\nexport const validateStrongRef = com.atproto.repo.strongRef.$safeValidate\n```\n\n- Types: `export type Foo = ns.path.TypeName` — `.Main` for the main\n  definition, the def's own name otherwise.\n- Guards: `export const isFooType = ns.path.$isTypeOf`. The `…Type` suffix is\n  intentional; it distinguishes a tag check from a full validation and avoids\n  colliding with the legacy `isFoo` names being replaced.\n- Validators: `export const validateFoo = ns.path.$safeValidate`.\n\nGrouping by NSID prefix with a comment header keeps a 200-line alias file\nnavigable.\n\n## Branded strings\n\nApply at type boundaries — function signatures, interface fields, DB row\ntypes — and leave runtime code alone:\n\n```diff\n- did: string                → did: DidString\n- handle: string             → handle: HandleString\n- handleOrDid: string        → handleOrDid: AtIdentifierString\n- indexedAt: string          → indexedAt: DatetimeString\n- iss: string                → iss: DidString | `${DidString}#${string}`\n```\n\nReplace prefix sniffing with the real guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nPrefer guards over assertion helpers: a guard lets the caller decide what a\nmalformed value means, while an assertion turns it into a 500.\n\nData crossing a boundary TypeScript can't see — protobuf messages, Kysely\nrows, `JSON.parse` — arrives as plain `string`. Cast once, at that entry\npoint:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n\n- qb.where('actor.did', '=', filter.sub!)\n+ qb.where('actor.did', '=', filter.sub! as DidString)\n```\n\nCasting later, after the value has been passed around, means every\nintermediate signature stays `string` and the brand buys nothing.\n\nThese types are re-exported from `@atproto/syntax` unchanged, so\n`import { DidString } from '@atproto/lex'` and\n`from '@atproto/syntax'` are the same type — migrated files use both, and\nfiles already importing other things from `@atproto/syntax` keep doing so.\nFull list and semantics: [lex-data skill](../../lex-data/SKILL.md).\n",".agents/skills/playwright/SKILL.md":"---\nname: playwright\ndescription: >\n  Drive and inspect this repo's OAuth provider UI and Account Manager (source in\n  packages/oauth/oauth-provider-ui, served by the PDS at /account). Use when\n  writing or extending the puppeteer-based UI tests in packages/pds/tests\n  (oauth.test.ts, account-manager.test.ts), when you need the exact visible\n  label, heading, or error string a screen renders before asserting on it, when\n  demoing, navigating or debugging an OAuth sign-in / sign-up / consent flow\n  locally or against production, when exercising account management (password,\n  email, handle, device sessions, deactivation, deletion), or when a question\n  about behavior can only be answered from the rendered page. Use it even when\n  the request sounds like plain test-writing (\"add a test for the reset-password\n  error case\") — getting the assertion strings right is the whole problem.\ndisable-model-invocation: false\n---\n\n# OAuth & Account Manager UI\n\nThe UI lives in [`packages/oauth/oauth-provider-ui`](../../../packages/oauth/oauth-provider-ui) (React 19, TanStack Router, Radix, Lingui i18n) and is served by the PDS through the OAuth provider's middleware. That package has its own [CLAUDE.md](../../../packages/oauth/oauth-provider-ui/CLAUDE.md) covering component structure and i18n — read it before editing the UI itself.\n\nThere is **no Playwright test harness in this repo**: no `playwright` / `@playwright/test` dependency, no `playwright.config.*`, no `.spec.ts` files. The browser tests are jest + **puppeteer** (`puppeteer` is a devDependency of `packages/pds` only). \"Playwright\" in this skill's name refers to driving a browser interactively for discovery and debugging, not to the test runner.\n\n## Get the strings without a browser first\n\nThe tests assert **French** strings, because they set `languages = ['fr-BE', 'fr', 'en-US', 'en']` to exercise locale negotiation. Booting a stack and spoofing a locale just to read a label is slow and easy to get wrong. Two faster sources:\n\n1. **The message catalog** — [`packages/oauth/oauth-provider-ui/src/locales/fr/messages.po`](../../../packages/oauth/oauth-provider-ui/src/locales/fr/messages.po) maps every source string to its French translation, with the defining component in a `#:` comment above it. Grep it for the English string you saw in the JSX and you have the exact assertion text.\n\n   ```\n   #: src/pages/account/(authenticated)/route.tsx\n   msgid \"My Atmosphere Account\"\n   msgstr \"Mon compte Atmosphère\"\n   ```\n\n   Watch for `msgctxt` — the same English word can have several entries (`\"Sign in\"` under `msgctxt \"verb\"` is `\"Se connecter\"`; the noun form differs).\n\n2. **The mock UI dev server** — renders the real components against a fake API, so no PDS, postgres, redis, or docker is involved:\n\n   ```sh\n   cd packages/oauth/oauth-provider-ui\n   pnpm dev:ui   # http://localhost:5174\n   ```\n\n   Pages: `/account-page.html`, `/authorization-page.html`, `/error-page.html`, `/cookie-error-page.html`. Fixtures (accounts, device sessions, branding) are in [`src/mock-api.ts`](../../../packages/oauth/oauth-provider-ui/src/mock-api.ts) — edit them to reach a state the real backend makes awkward (deactivated account, unverified email, many device sessions). It renders in your browser's own locale, so use it for layout and flow, and the `.po` file for the exact French text.\n\nBoot the full stack only when you need real backend behavior: token issuance, email tokens, PLC propagation, handle resolution, deactivation side effects.\n\n## Booting the real stack\n\n`packages/dev-env` wraps startup in [`with-test-redis-and-db.sh`](../../../packages/dev-infra/with-test-redis-and-db.sh), which needs **docker** and **jq** (it falls back to a native postgres/redis if docker is unavailable). Build first — it runs the compiled `dist/bin.js`, not the sources:\n\n```sh\npnpm install && pnpm build       # once, from the repo root\ncd packages/dev-env && pnpm dev  # PDS on :2583, PLC on :2582, appview on :2584\n```\n\nRun `pnpm dev:ts` from the root in another terminal if you're editing TypeScript alongside.\n\n- Account Manager: `http://localhost:2583/account`\n- Seeded accounts ([`packages/dev-env/src/mock/index.ts`](../../../packages/dev-env/src/mock/index.ts)): `alice.test`, `bob.test`, `carla.test`, all `hunter2`.\n- Setting `NODE_ENV=development` (which `pnpm dev` does) makes the OAuth provider log server-side errors and include stack traces in error responses — worth having when a flow fails opaquely.\n\n### Demo OAuth client\n\n```sh\ncd packages/oauth/oauth-client-browser-example\npnpm dev   # http://127.0.0.1:8080\n```\n\nBackend selection is the `env` query parameter, resolved in [`src/constants.ts`](../../../packages/oauth/oauth-client-browser-example/src/constants.ts). It defaults to `NODE_ENV`, which `pnpm dev` sets to `development` — so plain `http://127.0.0.1:8080/` already points at the local stack and `?env=development` is redundant.\n\nOnly `development` targets the local stack — every other value (including `test` and any typo) falls back to `bsky.social` / `api.bsky.app` for handle resolution and the appview. `development` and `test` additionally set `allowHttp` on the OAuth client, which is what lets either talk to a plain-HTTP loopback PDS.\n\n`?env=test` is therefore **not** a local-stack shortcut. It exists for `oauth.test.ts`, which overrides every endpoint explicitly via query params (`pds_operator_url`, `plc_directory_url`, `handle_resolver`, …) and serves its own bundle through `oauthClientAssetsMiddleware` on a random port, not 8080. Used by hand without those overrides it points at production with HTTP allowed — use `development` instead.\n\nAgainst `production`, sign in only with accounts you own — this is a real authorization grant against real infrastructure, not a sandbox.\n\n## Browser automation: confirm what you have\n\nThis repo registers two browser MCP servers in [`.mcp.json`](../../../.mcp.json) — `playwright` (`@playwright/mcp`) and `chrome-devtools` (`chrome-devtools-mcp`), both launched via `pnpx`. Registration is not connection: the server still has to be enabled and started in your harness, and permissions alone don't make tools exist. Run `claude mcp list` and work with what is actually connected rather than assuming `browser_*` tools are available.\n\nIf the Playwright server is registered but has no browser to drive:\n\n```sh\npnpx @playwright/mcp install-browser chrome-for-testing\n```\n\n**Known trap:** the repo root `package.json` declares `devEngines.packageManager: pnpm` with `onFail: \"error\"`, so **any `npx` invocation fails from the repo root** — including the ones MCP plugins use to launch their servers (`npx @playwright/mcp@latest`, `npx chrome-devtools-mcp@…`). Claude Code spawns MCP servers with cwd set to the project root, so a plugin-provided server can fail its health check here while working fine elsewhere. If `claude mcp list` reports a browser server as failed to connect, this is the likely cause; `pnpx` is unaffected, so a server entry using `pnpx` instead of `npx` starts cleanly.\n\nIf no browser MCP is reachable and you can't add one, fall back to the mock UI plus the `.po` catalog for discovery, and to running the puppeteer tests with `headless: false` (the option is already there, commented out, in both test files) for interactive debugging.\n\nTool-name note if you do have the Playwright MCP: `browser_navigate({ url })`, `browser_snapshot({ boxes })`, `browser_click({ element, target })`, `browser_type({ element, target, text })`, `browser_fill_form({ fields })`, `browser_wait_for({ text })`, `browser_console_messages({ level })`, `browser_network_requests({ static })`. `target` takes a ref from a prior snapshot or a CSS selector. Prefer `browser_snapshot` over screenshots — it returns the refs you need to interact, and screenshots don't.\n\n## Writing or extending a UI test\n\nThe two browser test files are [`packages/pds/tests/oauth.test.ts`](../../../packages/pds/tests/oauth.test.ts) and [`packages/pds/tests/account-manager.test.ts`](../../../packages/pds/tests/account-manager.test.ts). Read the neighbouring cases first — they are the best available spec for how these flows behave.\n\n**Discover before you assert.** Reading a component tells you what `<Trans>` wraps; it doesn't tell you which branch renders, what the negotiated locale produces, or what a Radix dialog actually puts in the DOM. Get the real strings from the mock UI, a driven browser, or the `.po` catalog, then write the test.\n\nBoth files share the `PageHelper` wrapper in [`packages/pds/tests/_puppeteer.ts`](../../../packages/pds/tests/_puppeteer.ts) — use it rather than raw puppeteer:\n\n- `goto(url)` / `reload()` — navigate.\n- `assertTitle(text)` — waits for network idle, then asserts `document.title`.\n- `clickOnText(text, tag = 'button')` / `clickOnAriaLabel(label, tag = 'button')` / `clickOn(selector)` — each waits for the element to be _visible_ first.\n- `typeInInput(name, text)` (targets `input[name=\"…\"]`) / `typeIn(selector, text)` — both clear the field first and return the `ElementHandle`, so you can `press('Enter')` on it.\n- `ensureTextVisibility(text, tag = 'p', timeout = 5000)` / `ensureNotification(text)` (`tag = 'div'`).\n- `waitForNetworkIdle()`, `navigationClick(text, tag)` / `navigationAction(fn)` for anything triggering a full navigation, `waitForPopup(fn)` → a `PageHelper` for the popup.\n\n`PageHelper` is `AsyncDisposable`; open pages with `await using page = await PageHelper.from(browser, { languages })` so they close even when a test throws.\n\nTwo recurring gotchas visible in the existing tests:\n\n- `clickOnText` matches on substring via puppeteer's `::-p-text()`, so a label that is a prefix of another (\"Réactiver\" inside \"Réactiver le compte\") hits whichever comes first in DOM order. Target the element directly instead — e.g. `clickOn('[role=\"dialog\"] button[type=\"submit\"]')`.\n- Mailed tokens are obtained by spying on the mailer (`jest.spyOn(network.pds.ctx.mailer, 'sendResetPassword')`) and reading `mock.lastCall`; those calls also assert the negotiated `locale`, which is how the i18n path stays covered.\n\n### Running\n\nThese suites are stateful — each `it` builds on the account state the previous one left behind — so run the whole file, never a single case:\n\n```sh\ncd packages/pds\npnpm test -- tests/oauth.test.ts\n```\n\nGo through `pnpm test`: it wraps jest in the postgres/redis script, and `pretest` installs the Chrome build puppeteer needs.\n\n## Related skills\n\n- [testing](../testing/SKILL.md) — runner choice (vitest vs jest), test file location, tsconfig setup. It routes browser-driven work here; come back to it for anything that isn't a UI test. New tests elsewhere in the repo should be vitest — these two files are jest because `pds` hasn't been migrated.\n",".agents/skills/testing/SKILL.md":"---\nname: testing\ndescription: >\n  Apply this monorepo's testing practices — runner choice (vitest vs jest),\n  where test files belong, tsconfig wiring, and the dev-env fixture used by\n  integration tests. Use before any code search when asked to add, write, or\n  extend tests, add coverage, reproduce a bug with a failing test, choose or\n  configure a test runner, decide where a test belongs, run a single test file,\n  or test a browser-based user flow. Covers unit, integration, and end-to-end\n  tests, and routes UI work to the playwright skill.\ndisable-model-invocation: false\n---\n\n# Testing in this repo\n\n## UI tests: get the assertion strings right first\n\nIf the test drives a UI (`packages/pds/tests/{oauth,account-manager}.test.ts`, or anything using the `puppeteer` `PageHelper`), the assertion strings are the whole problem — they are **French** and branch-dependent, so they can't be guessed from the JSX. Read the playwright skill before asserting on any of them: the fastest source is the `fr` message catalog, with a browser walkthrough as the fallback when the rendered branch is unclear.\n\nFull workflow, `PageHelper` API, and dev-env boot instructions: [playwright skill](../playwright/SKILL.md). Come back here for runner and tsconfig questions.\n\n## Choosing a runner\n\nVitest is the standard and now covers the majority of the repo (~25 packages, ~270 test files) — including `bsky`, all of `packages/lex/*`, `packages/oauth/*`, and the tested `packages/internal/*`. Jest remains in 13 unmigrated packages (~165 files), among them `pds`, `ozone`, `api`, `repo`, and `xrpc-server`. Jest is deprecated: don't introduce it anywhere new, and prefer migrating a file to vitest over deepening its jest usage.\n\nDecide from the package's own config files, not from a memorized list — they are the only drift-proof signal:\n\n1. Package has a `vite.config.*` → **Vitest, configured inside that Vite config** (never a separate `vitest.config.ts`). See [Vite-based packages](references/vitest.md#vite-based-packages).\n2. Package has a `vitest.config.ts` → Vitest. See [references/vitest.md](references/vitest.md).\n3. Package has a `jest.config.cjs` → Jest. See [references/jest.md](references/jest.md).\n4. None of the above → the package has no test setup. Adopt vitest: create `vitest.config.ts` and `tsconfig.test.json` first, following [the setup section](references/vitest.md#adopting-vitest-in-a-new-package), then write the test.\n\nIf the test imports `@atproto/lex` or any `@atproto/lex-*` package, also read the [lex-schema skill](../lex-schema/SKILL.md) — schemas expose `$parse` / `$safeParse` / `$matches`, which make better assertions than hand-rolled shape checks.\n\n## Where tests go\n\n- **Unit tests** sit next to their subject: `foo.ts` + `foo.test.ts`. This is the default whenever the test needs no infra and no cross-package setup.\n- **Integration / end-to-end tests** go in the package's top-level `./tests` folder (`packages/{pds,bsky,ozone}/tests`, `packages/lex/lex/tests`, …). Use it for tests that boot real services, hit a database, or exercise several modules together.\n- **Shared helpers** go in `./tests/_util.ts` (or a `./tests/_util/` directory). The leading underscore keeps them out of glob-based test discovery — see [packages/bsky/tests/\\_util.ts](../../../packages/bsky/tests/_util.ts) and [packages/ws-client/tests/\\_util/](../../../packages/ws-client/tests/_util).\n\nJest packages predate the colocation convention and keep everything in `./tests` — `packages/api/src/age-assurance.test.ts` is the lone exception. Don't relocate existing files just to match the convention.\n\n## Integration tests use the dev-env fixture\n\nAlmost every integration test in `pds`, `bsky`, `ozone`, and `lexicon-resolver` boots a real service constellation through `@atproto/dev-env` rather than mocking. Reach for it instead of hand-wiring servers:\n\n- `TestNetwork.create({ dbPostgresSchema })` — PDS + AppView + bsync + ozone + PLC. Needs postgres and redis. The schema must be unique per test file: it's the isolation boundary (the AppView and ozone databases are derived from it), so parallel files sharing one corrupt each other's data.\n- `TestNetworkNoAppView.create({ pds, plc })` — PDS + PLC only, on a temp-dir store and a mock PLC database. Lighter; use it when the test doesn't touch the AppView. It forwards only the `pds` and `plc` sub-options, so passing a top-level `dbPostgresSchema` here does nothing.\n- `TestPds.create(config, overrides)` — a lone PDS; pair with `TestPlc.create({})` and set `didPlcUrl: plc.url`. The optional second argument is passed to `PDS.create` as `AppContext` overrides (e.g. `{ plcRotationKey }`); neither network class forwards it.\n- `network.getSeedClient()` plus a seed exported from `@atproto/dev-env` (`basicSeed`, `usersSeed`, `quotesSeed`, …) populates accounts and records.\n- `await network.processAll()` flushes the firehose so the AppView has caught up before you assert.\n\nTear down with `afterAll(async () => network?.close())` — the prevailing pattern, and the only option for `TestNetworkNoAppView`, which exposes `close()` but no `Symbol.asyncDispose`. Only `TestNetwork` implements it.\n\nA test that resolves identities (handles, `did:plc`, `did:web`) against localhost must construct its resolver with `fetch: globalThis.fetch` — `@atproto/identity`'s default fetch is SSRF-protected and refuses localhost/private-IP targets silently.\n\nSnapshot assertions go through each package's `forSnapshot()` helper in `tests/_util.ts`, which swaps DIDs, CIDs, and timestamps for stable placeholders so snapshots don't churn on every run. Refresh them with `pnpm test:updateSnapshot` (defined in `bsky`, `pds`, `ozone`, `bsync`).\n\n## TypeScript config for tests\n\nTested packages split their TS config, each part referenced from the package's `tsconfig.json`:\n\n```json\n{\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.config.json\" },\n    { \"path\": \"./tsconfig.build.json\" },\n    { \"path\": \"./tsconfig.test.json\" }\n  ]\n}\n```\n\n- `tsconfig.build.json` — `./src`, excludes `**/*.test.ts`, emits to `./dist`.\n- `tsconfig.test.json` — test code, extending `tsconfig/vitest.tsconfig.json` or `tsconfig/jest.tsconfig.json`. Both set `noEmit` and `composite: false`; the jest one additionally pulls in `@types/jest`. `include` is `[\"./tests\", \"./src/**/*.test.ts\"]` in nearly every package, regardless of runner.\n- `tsconfig.config.json` — root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, …) that neither of the above covers, extending [tsconfig/config.tsconfig.json](../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]`. `exclude` any root file that imports `./src` or `./tests` (e.g. a `jest.setup.ts`), or it drags the whole tree into this stricter program. Present whenever the package has such a file — which is nearly all of them.\n\nThe build is a TS project graph, so `references` is what makes imports resolve. `./tsconfig.build.json` alone covers anything the package already depends on at build time — which is most workspace imports. Add a further entry only for a package the tests import but `src` doesn't: in practice that means `{ \"path\": \"../dev-env/tsconfig.build.json\" }`, and only in the six packages with integration tests (`api`, `bsky`, `lexicon-resolver`, `ozone`, `pds`, `sync`).\n\nCreate `tsconfig.test.json` before writing the first test in a package that lacks one — `packages/did` has jest tests without one, so don't take its layout as the pattern. Exact contents per runner: [vitest](references/vitest.md#adopting-vitest-in-a-new-package) / [jest](references/jest.md#typescript-config).\n\n## Running tests\n\nRun from inside the package directory:\n\n```bash\npnpm test                        # full package suite\npnpm test path/to/file.test.ts   # single file\n```\n\nPackages whose tests need docker infra wrap the runner in a dev-infra script — `bsky`, `pds`, `ozone`, and `sync` use [with-test-redis-and-db.sh](../../../packages/dev-infra/with-test-redis-and-db.sh); `bsync` uses `with-test-db.sh`. Always go through `pnpm test`; invoking `vitest` or `jest` directly skips the script, so postgres and redis aren't running and the suite dies on connection errors. `pds` also offers `pnpm test:sqlite` for a faster loop that skips the docker infra.\n\nFrom the repo root, `pnpm test` runs every package's suite with infra up, and `pnpm test:unit` runs only the projects registered in [vitest.config.ts](../../../vitest.config.ts). That list is not the full set of vitest packages: `bsky` is commented out because it needs infra, and `lexicon-resolver` is simply absent. Run either from its own directory.\n\n## Logs\n\nEvery part of the code base uses pino logging, which is disabled by default. Enable it with `LOG_ENABLED=true` and redirect to a file with `LOG_DESTINATION=...`. For example, to run the full test suite and capture logs:\n\n```bash\nLOG_ENABLED=true LOG_DESTINATION=test.log pnpm test ...\n```\n\nPretty print using:\n\n```bash\ncat test.log | pnpm exec pino-pretty\n```\n\n## Code style\n\n- Always use `using` for spies, mocks and any object that implements `Disposable` or `AsyncDisposable`.\n",".agents/skills/testing/references/jest.md":"# Jest patterns (deprecated)\n\nJest (currently v30) is deprecated here; vitest is the standard. This reference covers adding cases to existing jest files and maintaining them until migration. Don't introduce jest into a package that doesn't already have it — for a fresh package, see [vitest.md](vitest.md).\n\nA package is on jest if it has a `jest.config.cjs` and a `\"test\"` script invoking `jest` with `NODE_OPTIONS=--experimental-vm-modules` (required because the repo is ESM-only). The root [jest.config.cjs](../../../../jest.config.cjs) aggregates them via `projects: ['<rootDir>/packages/*/jest.config.cjs']` — note the single-level glob, so a jest package nested under `packages/lex/` or `packages/oauth/` would not be picked up. Check the package's own config rather than trusting any list.\n\n## TypeScript config\n\nJest packages extend [tsconfig/jest.tsconfig.json](../../../../tsconfig/jest.tsconfig.json), which adds `\"types\": [\"node\", \"jest\"]` on top of the shared node config:\n\n```jsonc\n{\n  \"extends\": [\"../../tsconfig/jest.tsconfig.json\"],\n  \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n  \"compilerOptions\": {\n    \"rootDir\": \".\",\n  },\n  \"references\": [\n    { \"path\": \"./tsconfig.build.json\" },\n    // Integration tests only — see below\n    { \"path\": \"../dev-env/tsconfig.build.json\" },\n  ],\n}\n```\n\n`./tsconfig.build.json` covers the package's own build-time deps and is all most jest packages have. Of the 13, only `api`, `ozone`, `pds`, and `sync` add the `dev-env` entry, because their tests import `@atproto/dev-env` while `src` doesn't. `packages/did` has jest tests but no `tsconfig.test.json` at all — a gap, not a pattern.\n\nThe `include` glob covers colocated `src/**/*.test.ts` even though jest packages barely use them (`packages/api/src/age-assurance.test.ts` is the only one) — leave it as-is so the two runners' configs stay uniform. A package adding custom matchers also needs them declared: [packages/api](../../../../packages/api/tsconfig.test.json) sets `\"types\": [\"jest\", \"./jest.d.ts\"]` to pick up its ambient `jest.d.ts`.\n\n## Writing tests\n\nMost structural conventions from [vitest.md](vitest.md) carry over — `describe`/`it`, `it.each`, fixtures at the top of the file, `using` for spies (jest 30's spies are `Disposable` too). What differs:\n\n| vitest                   | jest                                                                                                                              |\n| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |\n| `vi.fn<FnType>()`        | `jest.fn<FnType>()` (single type param since jest 30)                                                                             |\n| `vi.spyOn` / `vi.mock`   | `jest.spyOn` / `jest.mock`                                                                                                        |\n| `assert()` from `vitest` | `import assert from 'node:assert'` — the prevailing pattern in `pds` tests                                                        |\n| `rejects.toSatisfy(cb)`  | not a jest matcher; use `rejects.toMatchObject({ error: '…' })`, or `const err = await fn().catch((e) => e)` then assert on `err` |\n| `expectTypeOf`           | no equivalent; skip type-level assertions or migrate the file                                                                     |\n\nJest globals are ambient via `@types/jest`, but a minority of files import them explicitly from `@jest/globals`. Match whatever the surrounding files in the package do rather than mixing styles within one package.\n\n## Setup files\n\nTranspilation is `@swc/jest`, not `ts-jest`, with a `moduleNameMapper` that resolves the repo's explicit `.js` import extensions back to `.ts` sources.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests). Jest does accept a path after `--`, but omitting `--` works in both runners — so just use `pnpm test path/to/file.test.ts` and the habit stays correct when the file migrates.\n",".agents/skills/testing/references/vitest.md":"# Vitest patterns\n\nRepo-specific conventions and setup. Assumes working knowledge of vitest itself (currently v4) — only the things this codebase does differently from the defaults are documented here.\n\n## Adopting vitest in a new package\n\n1. Add `vitest` to `devDependencies` and set `\"test\": \"vitest run\"` in `package.json`.\n2. Create `vitest.config.ts` in the package root. Nearly every package in the repo has exactly this and nothing more:\n\n   ```ts\n   import { defineProject } from 'vitest/config'\n\n   export default defineProject({\n     test: {},\n   })\n   ```\n\n   `defineProject` (not `defineConfig`) is what makes the package composable into the root workspace runner. Add `setupFiles`, `testTimeout`, `resolve.alias`, etc. only when the package actually needs them — [packages/bsky](../../../../packages/bsky/vitest.config.ts) and [packages/ws-client](../../../../packages/ws-client/vitest.config.ts) are the only two that do.\n\n   **Exception:** if the package already has a `vite.config.*`, don't create `vitest.config.ts` — see [Vite-based packages](#vite-based-packages).\n\n3. Register the package in the root [vitest.config.ts](../../../../vitest.config.ts) under `test.projects` so `pnpm test:unit` and the VSCode Vitest extension pick it up. Several entries are globs (`packages/lex/*`, `packages/oauth/*`, `packages/internal/*`), so a package placed under one of those directories needs no edit.\n\n   **Skip this step if the tests need dev-infra** (the postgres + redis docker stack). The workspace runner doesn't start dev-infra, so those tests would fail when invoked from the root. That's why `packages/bsky` is commented out of the list — it must run from its own directory via `pnpm test`, which goes through [with-test-redis-and-db.sh](../../../../packages/dev-infra/with-test-redis-and-db.sh). `packages/lexicon-resolver` is also missing from the list, but without a comment and without a dev-infra wrapper on its `test` script, so treat its absence as an oversight rather than a pattern to copy.\n\n4. Create `tsconfig.test.json` extending the shared vitest config (never the jest one, which pulls in `@types/jest`):\n\n   ```json\n   {\n     \"extends\": [\"../../tsconfig/vitest.tsconfig.json\"],\n     \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n     \"compilerOptions\": {\n       \"rootDir\": \".\"\n     },\n     \"references\": [{ \"path\": \"./tsconfig.build.json\" }]\n   }\n   ```\n\n   Adjust the `../../` depth for nested packages (`packages/lex/*` and `packages/oauth/*` need `../../../`). `./tsconfig.build.json` is normally the only reference needed — it already pulls in the package's build-time deps, so a test importing `@atproto/lex-data` from `lex-json` resolves without an extra entry. Add one only for a package the tests import but `src` doesn't; that's `{ \"path\": \"../dev-env/tsconfig.build.json\" }` in the integration-test packages, and little else.\n\n   Extend `include` for other test-only sources: benchmarks (`./src/**/*.bench.ts`, as [lex-json](../../../../packages/lex/lex-json/tsconfig.test.json) does) or ambient declarations (`./src/core-js.d.ts`, as [lex-document](../../../../packages/lex/lex-document/tsconfig.test.json) does).\n\n5. Make sure the package's `tsconfig.json` references `./tsconfig.build.json`, `./tsconfig.test.json`, and — so the new `vitest.config.ts` gets type-checked — `./tsconfig.config.json`. If the package has no `tsconfig.config.json` yet, create one extending [tsconfig/config.tsconfig.json](../../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]` (adjust the `../../` depth for nested packages). See the SKILL's [TypeScript config](../SKILL.md#typescript-config-for-tests) section.\n\n## Vite-based packages\n\nA package with a `vite.config.*` must use vitest, configured **inside that existing Vite config**. Vitest reads `vite.config.*` natively, so a separate `vitest.config.ts` would shadow the package's real build setup (plugins, `resolve.alias`, `optimizeDeps`, `conditions`) and the tests would run against a different module graph than the app. Jest isn't an option at all here — it can't consume the Vite plugin pipeline.\n\nTwo additions to the Vite config, as in [oauth-provider-ui](../../../../packages/oauth/oauth-provider-ui/vite.config.js):\n\n1. `/// <reference types=\"vitest/config\" />` on the **first line** — this types the `test` key, which `defineConfig` from `vite` doesn't know about on its own.\n2. A `test: {}` key in the exported config, kept empty unless an option is genuinely needed.\n\nEverything else is unchanged: `vitest` in `devDependencies`, `\"test\": \"vitest run\"`, root `projects` registration, and a `tsconfig.test.json`.\n\nBecause the tests share the app's Vite config, they inherit its `resolve.alias` — import through the package's own aliases (`#/lib/foo.js`) exactly as production code does, rather than reaching for brittle relative paths.\n\n## Imports\n\nImport test utilities by name from `vitest`; nothing is ambient:\n\n```ts\nimport { assert, describe, expect, it, vi } from 'vitest'\n```\n\nPrefer vitest's `assert` over `node:assert` — it narrows types and reports through vitest's error formatting.\n\n## Labels\n\n`it` and `test` are aliases at runtime, but this repo picks between them by what the label _is_:\n\n- **`it('<description>', …)`** when the label is a sentence describing behavior. Reads as \"it rejects invalid request bodies\".\n- **`test(case, …)`** when the label identifies a specific case: a function reference, a fixture name, a parameterized row. Reads as \"test `parseCid`\".\n\nFor `describe`, pass the function or class **reference** rather than a string when the suite covers a single named export — the label stays correct through renames:\n\n```ts\ndescribe(parseCid, () => { … })\ndescribe(XrpcResponseError, () => { … })\n```\n\nUse a string label for conceptual groups that don't map to one export — an arrow function assigned to a const, a class method, a roundtrip pair (`describe('roundtrip toBase64 <-> fromBase64', …)`). Integration suites under `./tests` span many endpoints and are string-labeled by convention: `describe('pds profile views', …)`.\n\n## Parameterized tests\n\nPrefer `test.each` over a hand-rolled `for` loop. Each row is a case identifier, hence `test` and not `it`:\n\n```ts\ndescribe(isReconnectableClose, () => {\n  test.each([\n    { note: 'normal shutdown', code: CloseCode.Normal, expected: false },\n    { note: 'internal error', code: CloseCode.InternalError, expected: true },\n  ])('$note', ({ code, expected }) => {\n    expect(isReconnectableClose(code)).toBe(expected)\n  })\n})\n```\n\nThe label template is `'$note'` — `$`-prefixed, referring to a property of the row object. `'{note}'` does _not_ interpolate; it renders literally, so every case ends up with the same name.\n\nTo run one suite against several implementations of an interface, the implementation is the case identifier, so `describe.each` takes it — but the inner labels still describe behavior, so they stay `it`:\n\n```ts\ndescribe.each([utf8LenNode, utf8LenCompute])('%o', (utf8Len) => {\n  // some implementations are runtime-optional; assert before the tests read them\n  assert(utf8Len, 'utf8Len implementation should not be null')\n\n  it('computes utf8 string length', () => {\n    expect(utf8Len('a')).toBe(1)\n  })\n})\n```\n\nFall back to `for (const c of cases) { test(c.name, …) }` only when the body shape genuinely can't be expressed as `test.each` — different nesting or a different number of assertions per case.\n\n### Fixture files\n\nImport JSON fixtures with the import attribute and hand the array straight to `test.each`, keyed on a label property:\n\n```ts\nimport fixtures from './data-model-valid.json' with { type: 'json' }\n\ntest.each(fixtures)('$note', (fixture) => { … })\n```\n\nFor the shared text fixtures in `interop-test-files/`, use the existing [`readInteropFile`](../../../../packages/syntax/tests/_utils.ts) helper rather than re-implementing the read-and-filter logic:\n\n```ts\ntest.each(readInteropFile('aturi_syntax_valid.txt'))('%s', (value) => { … })\n```\n\n## Assertions\n\n### `assert` for narrowing\n\n`assert()` narrows types; `expect(…).toBe(true)` does not. Preferring it means the rest of the test can read narrowed properties without a second guard or a cast:\n\n```ts\nassert(result.success)\nexpect(result.body).toEqual({ value: 'hello' }) // result.body is typed here\n```\n\nThe same applies to instance checks before property assertions:\n\n```ts\nassert(err instanceof XrpcFetchError)\nexpect(err.cause).toBeInstanceOf(TypeError)\n```\n\n`expect(result).toMatchObject({ success: true })` is the worst of the three — no narrowing, and a weaker diagnostic on failure.\n\n### Rejections\n\n- `rejects.toThrow()` — the only assertion is that something threw.\n- `rejects.toThrow(message)` — the only assertion beyond that is the message, as a string or regex.\n- `rejects.toSatisfy(…)` — everything else, including checking exactly one non-message property (`err.cause`, `err instanceof X`, `err.code`).\n\nInside a `toSatisfy` callback, a failing `expect()` or `assert()` propagates as the test failure, so the assertions read normally and no try/catch is needed. The callback has to `return true` at the end — that line is only reached when everything passed:\n\n```ts\nawait expect(someAsyncFn()).rejects.toSatisfy((err) => {\n  assert(err instanceof SomeError)\n  expect(err.cause).toBeInstanceOf(TypeError)\n  return true\n})\n```\n\n### Type-level assertions\n\nUse `expectTypeOf` rather than a hand-rolled `const expectType = <T>(_: T) => {}` helper — it compiles to nothing, so it's free at runtime. This repo uses `toEqualTypeOf` for exact matches and `toMatchObjectType` for partial ones. Avoid `toMatchTypeOf`, which upstream deprecated in favor of those two.\n\n```ts\nexpectTypeOf(result.body).toEqualTypeOf<{ blobRef: l.BlobRef }>()\nexpectTypeOf(response.body).toMatchObjectType<{ cursor?: string }>()\n```\n\nFor deliberately invalid arguments, `// @ts-expect-error` with a reason:\n\n```ts\n// @ts-expect-error intentionally passing invalid params\nparams: { limit: 'not-a-number' },\n```\n\n## Mocks and spies\n\nType `vi.fn` with the function type when the test inspects how it was called:\n\n```ts\nconst fetchHandler = vi.fn<FetchHandler>(async () =>\n  Response.json({ value: 'ok' }),\n)\n\nawait xrpc(fetchHandler, testQuery, { params: { limit: 25 } })\n\nexpect(fetchHandler).toHaveBeenCalledOnce()\nconst [path] = fetchHandler.mock.calls[0]\nexpect(path).toContain('/xrpc/io.example.testQuery')\n```\n\nWhen calls don't need inspecting, a plain typed function is clearer than a mock: `const fetchHandler: FetchHandler = async () => …`.\n\n### Module mocks\n\nMocking a node built-in usually needs both the default and the named exports, since callers may use either form ([lexicon-resolver](../../../../packages/lexicon-resolver/tests/lexicon.test.ts) does this for DNS):\n\n```ts\nvi.mock('node:dns/promises', () => {\n  const mock = { resolveTxt: vi.fn() }\n  return { default: mock, ...mock }\n})\n```\n\nTo override only part of a module, spread the actual one:\n\n```ts\nvi.mock('./const.js', async () => {\n  const actual = await vi.importActual<Record<string, unknown>>('./const.js')\n  return { ...actual, KWS_V2_COUNTRIES: new Set(['AA']) }\n})\n```\n\nNote that `bsky` integration tests mock the **compiled** path (`../../dist/api/…`), not `../../src/…`, because the service under test is loaded from `dist`. Match whatever the module graph actually resolves, or the mock silently won't apply.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests) — in particular, don't put `--` before a file path.\n",".agents/skills/update-chat-lexicons/SKILL.md":"---\nname: update-chat-lexicons\ndescription: >\n  Sync `chat.bsky.*` Lexicon JSON from the private `bluesky-social/chat` repo\n  into this monorepo, regenerate the code derived from it, and add a changeset.\n  Use whenever asked to update, sync, pull, refresh, or copy chat lexicons, to\n  pick up new or changed chat convo, group, moderation, or notification\n  definitions, or to reconcile `lexicons/chat/` with the chat service —\n  including when the request is just \"update chat lexicons\".\ndisable-model-invocation: false\n---\n\n# Update chat lexicons\n\n`lexicons/chat/bsky/**` is a mirror. The source of truth is the separate\n`bluesky-social/chat` repo, so an update is a copy _plus_ codegen _plus_ a\nchangeset — the copy on its own leaves the generated clients stale.\n\n## 1. Refresh the source checkout\n\nThis assumes a sibling clone at `../chat`. Update it first: step 2 copies with\n`--delete`, so syncing from a checkout that is behind will delete lexicons that\nstill exist upstream.\n\n```bash\ngit -C ../chat pull --ff-only\n```\n\nIf `../chat` doesn't exist, `bluesky-social/chat` is private and not\nweb-fetchable — clone it with `gh repo clone bluesky-social/chat ../chat`, or\nask the user where their checkout lives and substitute that path below. Don't\nimprovise a source from the destination; there would be nothing to sync.\n\n## 2. Copy\n\n```bash\nrsync -a --delete ../chat/lexicons/chat/ ./lexicons/chat/\n```\n\nOnly `lexicons/chat/` is mirrored. The chat repo also carries `app/`, `com/`,\nand `tools/` lexicons that diverge from this repo's — never sync those.\n\nCheck `git status` immediately. Unexpected deletions almost always mean the\nsource checkout is behind, not that definitions were dropped upstream.\n\n## 3. Regenerate\n\n```bash\npnpm codegen\n```\n\nRun this from the repo root, and use `pnpm codegen` rather than `pnpm build`:\n`packages/api` and `packages/ozone` regenerate only when a lexicon file is\n_newer_ than their generated index, and `rsync -a` preserves the source's\nmtimes — so a build can decide nothing changed and silently keep stale types.\n`pnpm codegen` invokes the generators unconditionally, building the codegen\ntooling itself first.\n\nBoth generated trees are gitignored, so the commit diff shows only JSON. The\nreal blast radius stays invisible until you build.\n\n## 4. Changeset\n\nAdd a file under `.changeset/` with a three-word kebab-case name:\n\n```markdown\n---\n'@atproto/api': patch\n---\n\nupdate chat lexicons\n```\n\nA patch on `@atproto/api` alone is the established convention here (see\n`git log --oneline -- lexicons/chat`). `@atproto/ozone` also generates from\nthese lexicons but picks up an automatic dependency bump, so don't list it by\nhand. Use `minor` instead if the sync removes or renames a definition — that\nbreaks the published types.\n\n## 5. Verify\n\n```bash\n(cd packages/api && pnpm build && pnpm test)\n(cd packages/ozone && pnpm build)\n```\n\nOzone's build typechecks its route handlers against the regenerated server\ntypes, which is where a removed or narrowed chat definition actually surfaces.\n\nThen review the diff and commit. Before opening a PR, run the full checks from\nthe root: `pnpm build --force && pnpm verify`.\n",".agents/skills/xrpc-server/SKILL.md":"---\nname: xrpc-server\ndescription: >\n  Build or edit routes on an AT Protocol service that uses\n  `@atproto/xrpc-server` — `packages/pds`, `packages/bsky`, or any package whose\n  handlers call `server.add()`. Use when adding or changing an endpoint, wiring\n  `createServer` into Express, reading params/input/auth in a handler, returning\n  JSON, binary, streamed, or empty responses, setting response headers, throwing\n  XRPC protocol errors, applying per-route rate limits or body limits, or\n  exposing a WebSocket subscription. To migrate a service off the legacy\n  generated server stack, use the lexification-server skill instead. For calls\n  the service makes *out* to other services, use the lex-client skill.\ndisable-model-invocation: false\n---\n\n# `@atproto/xrpc-server`\n\nThe server half of the AT Protocol XRPC stack. Service code creates a `Server`\nand registers endpoints by passing generated lexicon schemas to `server.add()`.\n\nImport it directly — it is **not** re-exported by `@atproto/lex`:\n\n```ts\nimport { InvalidRequestError, type Server } from '@atproto/xrpc-server'\n```\n\nThis is the server package the services actually use — `@atproto/lex-server` is\na separate package that no service in this repo consumes. `packages/pds`,\n`packages/bsky` and `packages/ozone` are all fully on `server.add()`.\n\nWatch the casing: `XRPCError` is this package's server-side class, thrown in\nhandlers. `XrpcError` is the _client_-side class from `@atproto/lex-client`.\n\n## Creating the server\n\n```ts\nimport { createServer } from '@atproto/xrpc-server'\n\nconst server = createServer([], {\n  validateResponse: config.debugMode,\n  payload: {\n    jsonLimit: 100 * 1024,\n    textLimit: 100 * 1024,\n    blobLimit: 5 * 1024 * 1024,\n  },\n})\n\napp.use(server.router) // Express\n```\n\nThe first argument is an array of legacy `LexiconDoc`s — pass `[]` for\nschema-based setups. It is not middleware. Other `Options` worth knowing:\n`catchall` (a fallback `RequestHandler`, used by the PDS for proxying),\n`errorParser` (map non-`XRPCError` throwables onto XRPC errors before they are\nrendered), and `rateLimits` (global/shared limiter wiring).\n\n`server.router` is the Express app. Legacy generated servers expose\n`server.xrpc.router` instead — if you see that, you are in legacy code.\n\nWebSocket upgrades are wired up automatically when the server's router is\nmounted on an app that later calls `.listen()`; there is nothing extra to do\nfor subscriptions beyond registering them.\n\n## Registering a route\n\n`server.add(schema, handlerOrConfig)`. The schema is the generated lexicon\nobject, e.g. `com.atproto.identity.resolveHandle`.\n\nBare-function form when the route needs nothing but a handler:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../lexicons/index.js'\n\nexport default function (server: Server, ctx: AppContext) {\n  server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n    const did = await resolve(params.handle)\n    return { encoding: 'application/json', body: { did } }\n  })\n}\n```\n\nObject form for anything else — `auth`, `rateLimit`, `opts`:\n\n```ts\nserver.add(app.bsky.draft.createDraft, {\n  auth: ctx.authVerifier.standard,\n  handler: async ({ input, auth }) => {\n    const actorDid = auth.credentials.iss\n    if (atLimit)\n      throw new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\n    return { encoding: 'application/json', body: { draft } }\n  },\n})\n```\n\nRegistration is conditional-friendly: it is normal to `return` early when a\ndependency is absent (`if (!ctx.bskyAppView) return`), or to register a\ndifferent handler for the same NSID on each branch of an `if`.\n\n### Handler context\n\n`{ params, input, auth, req, res, resetRouteRateLimits }`. Queries read\n`params`, procedures read `input.body`; `req`/`res` are Express objects, useful\nfor reading request headers or setting response headers directly.\n\nThere is **no `signal`** on a method handler — only subscription handlers get\none. `resetRouteRateLimits()` clears the consumed points for this route,\ne.g. after a successful login.\n\n### Route options and rate limits\n\n```ts\nserver.add(com.atproto.repo.createRecord, {\n  auth: ctx.authVerifier.authorization({ authorize: () => {} }),\n  rateLimit: [\n    {\n      name: 'repo-write-hour',\n      calcKey: ({ auth }) => auth.credentials.did,\n      calcPoints: () => 3,\n    },\n  ],\n  opts: { jsonLimit: 1_000_000 },\n  handler: async ({ input, auth }) => {\n    /* ... */\n  },\n})\n```\n\n`rateLimit` is one entry or an array; `{ name }` references a shared limiter\ndeclared in `createServer`'s `rateLimits.shared`, while `{ durationMs, points }`\ndeclares a route-local one. `opts` is `RouteOptions` — `jsonLimit`, `textLimit`,\n`blobLimit`, `paramsParseLoose` — overriding the server-wide `payload` defaults.\n\n## Return values\n\nA JSON response is `{ encoding, body }`, optionally with `headers`:\n\n```ts\nreturn {\n  encoding: 'application/json',\n  body: result,\n  headers: { 'server-timing': serverTimingHeader([timerSkele, timerHydr]) },\n}\n```\n\nOther shapes the server understands:\n\n| Return                           | Effect                                                                   |\n| -------------------------------- | ------------------------------------------------------------------------ |\n| nothing (`undefined`)            | 200 with an empty body — correct for lexicons with no `output`           |\n| `{ encoding, body: Readable }`   | streamed binary response                                                 |\n| `{ encoding, buffer, headers? }` | `HandlerPipeThroughBuffer`                                               |\n| `{ encoding, stream, headers? }` | `HandlerPipeThroughStream` — what the PDS `pipethrough()` helper returns |\n\nBinary example, from `packages/pds/src/api/com/atproto/sync/getBlob.ts`:\n\n```ts\nres.setHeader('content-security-policy', `default-src 'none'; sandbox`)\nreturn {\n  encoding: found.mimeType || ('application/octet-stream' as const),\n  body: found.stream,\n}\n```\n\nBodies are validated against the schema's output unless the server was\nconstructed with `validateResponse: false`.\n\n### When `encoding` needs `as const`\n\nReturning the object literal _directly_ from a handler that declares at least\none parameter is contextually typed by `server.add()`, so `encoding:\n'application/json'` is already narrow and needs nothing — this is what most\nhandlers in `packages/bsky/src/api/` do.\n\nContextual typing is lost in two cases, and then `'application/json'` widens to\n`string` and no longer matches the schema:\n\n- the response is built into an intermediate variable before being returned\n- the handler declares no parameters at all (`async () => ({ ... })`)\n\nFix either with `as const` on the literal, or by anchoring the whole object with\n`satisfies app.bsky.graph.getRelationships.$Output`. A\n`Promise<…$Output>` return-type annotation does **not** fix the\nintermediate-variable case — it produces an assignability error instead.\n\n## Errors\n\nThrow an `XRPCError` subclass. Constructors are\n`(message?, errorName?, options?)`; `errorName` should match an error declared\nin the lexicon, and `options` is standard `ErrorOptions` so causes can be\nchained:\n\n```ts\nthrow new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\nthrow new InvalidRequestError('Unable to resolve handle', undefined, {\n  cause: err,\n})\n```\n\n| Class                       | Status |\n| --------------------------- | ------ |\n| `InvalidRequestError`       | 400    |\n| `AuthRequiredError`         | 401    |\n| `ForbiddenError`            | 403    |\n| `InternalServerError`       | 500    |\n| `MethodNotImplementedError` | 501    |\n| `UpstreamFailureError`      | 502    |\n| `NotEnoughResourcesError`   | 503    |\n| `UpstreamTimeoutError`      | 504    |\n\n`RateLimitExceededError` (429) also exists but takes a `RateLimiterStatus` as its\nfirst argument and is thrown by the rate limiter, not by handlers.\n\nEach subclass overrides `Symbol.hasInstance` to match on the response type\nrather than the prototype chain, so `err instanceof InvalidRequestError` is true\nfor _any_ `XRPCError` with a 400 type, however it was constructed.\n\n`ErrorResult` (`{ status, error?, message? }`) is the non-throwing counterpart,\nreturned by auth verifiers; `excludeErrorResult(v)` converts one back into a\nthrown `XRPCError`.\n\n## Schema accessors inside handlers\n\nThe schema object is in scope — use its accessors instead of NSID string\nliterals (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md)):\n\n```ts\nconst lxm = app.bsky.actor.getPreferences.$lxm // 'app.bsky.actor.getPreferences'\nconst aud = computeProxyTo(ctx, req, lxm)\npermissions.assertRpc({ aud, lxm })\n```\n\n| Accessor        | Use                                                                  |\n| --------------- | -------------------------------------------------------------------- |\n| `$lxm`          | lexicon method id, for auth scopes and proxy targets                 |\n| `$type`         | record type, e.g. `uri.collection === app.bsky.feed.post.$type`      |\n| `$token`        | a token def's string value (`app.bsky.graph.defs.curatelist.$token`) |\n| `$build({...})` | construct a value with its `$type` set                               |\n\n`$build` is how union members are constructed:\n\n```ts\napp.bsky.graph.defs.notFoundActor.$build({ actor, notFound: true })\n```\n\nFor shapes that carry no `$type`, just annotate or `satisfies` the exported type\n(`com.atproto.server.defs.InviteCode`).\n\n## Type-only references\n\n```ts\ntype Params = app.bsky.feed.getAuthorFeed.$Params\ntype Output = app.bsky.feed.getAuthorFeed.$Output // { encoding, body }\ntype Body = app.bsky.feed.getAuthorFeed.$OutputBody // just the body\ntype PostRecord = app.bsky.feed.post.Main\ntype PostView = app.bsky.feed.defs.PostView\n```\n\n`$OutputBody` is the one you want for helper functions that compute a response\nbody; `$Output` includes the `encoding` wrapper. Inputs mirror this with\n`$Input` / `$InputBody`, and subscriptions use `$Message`.\n\n## Subscriptions\n\nRegister a subscription schema with an async generator. Yield `$build()`-ed\nmessages; the server frames and (unless `validateResponse: false`) validates\neach one. `signal` aborts when the client disconnects — thread it into whatever\nproduces events so the generator unwinds.\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    const { cursor } = params\n    const curr = await ctx.sequencer.curr()\n    if (cursor !== undefined && cursor > (curr ?? 0)) {\n      throw new InvalidRequestError('Cursor in the future.', 'FutureCursor')\n    }\n    for await (const evt of outbox.events(cursor, signal)) {\n      yield com.atproto.sync.subscribeRepos.commit.$build({\n        seq: evt.seq,\n        time: evt.time,\n        ...evt.evt,\n      })\n    }\n  },\n)\n```\n\nAnnotating the generator's return type keeps errors on the offending `yield`\nrather than on the whole `server.add()` call. Throwing before the first yield\nrejects the connection with that error. The subscription context is\n`{ params, auth, req, signal }` — and `req` here is a raw Node\n`IncomingMessage`, not an Express `Request`.\n\n`packages/pds/src/api/com/atproto/sync/subscribeRepos.ts` is the only\nsubscription in the repo and the reference implementation.\n\n## File layout\n\nHandlers live under `src/api/<nsid-path>.ts`, one file per method, filename\nmatching the method name in camelCase (the documented exception to the\nkebab-case rule), each default-exporting a registrar:\n\n```\npackages/bsky/src/api/\n  app/bsky/feed/getAuthorFeed.ts   ← export default function (server, ctx) { server.add(...) }\n  com/atproto/identity/resolveHandle.ts\n  index.ts                         ← imports each module and calls it\n```\n\n`src/api/index.ts` is a flat list of imports followed by a flat list of\n`handler(server, ctx)` calls; add new routes in both places.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  `server.add()`, and their `$`-accessors. Needed alongside this skill for\n  almost any route work.\n- **[lex-data](../lex-data/SKILL.md)** — branded strings, `BlobRef`, CBOR, and\n  datetimes at route boundaries.\n- **[lex-client](../lex-client/SKILL.md)** — when the service also calls _out_\n  to other services.\n- **[lexification-server](../lexification-server/SKILL.md)** — migrating an\n  existing service off the legacy `lex gen-server` stack onto the patterns\n  above.\n- **[lex-setup](../lex-setup/SKILL.md)** — lexicon install/build configuration.\n- **[testing](../testing/SKILL.md)** — runner choice and test placement when\n  testing route handlers.\n","AGENTS.md":"# AGENTS.md\n\n## Repository overview\n\nThis is the TypeScript reference implementation of [AT Protocol](https://atproto.com), the decentralized social media protocol behind Bluesky. It is a pnpm monorepo (runtime floor Node.js ≥22) containing client libraries, schema/codegen tooling, and the two main service implementations: the Personal Data Server (PDS) and the `app.bsky` AppView.\n\nWorkspace layout (see [pnpm-workspace.yaml](./pnpm-workspace.yaml) and [tsconfig.json](./tsconfig.json)):\n\n- [packages/\\*](packages/) — top-level libraries: `api`, `common`, `crypto`, `identity`, `lexicon`, `repo`, `syntax`, `xrpc`, `xrpc-server`, `pds`, `bsky`, `bsync`, `ozone`, `dev-env`, `dev-infra`, etc.\n- [packages/lex/\\*](packages/lex/) — the modern type-safe Lexicon SDK family (`@atproto/lex`, `lex-builder`, `lex-cbor`, `lex-client`, `lex-data`, `lex-document`, `lex-json`, `lex-resolver`, `lex-server`, `lex-schema`, `lex-installer`, `lex-password-session`). New service code should use this in preference to the older `@atproto/api` / `@atproto/lexicon` / `@atproto/xrpc` / `@atproto/lex-cli` stack — see the `lex-*` skills listed under [Codegen](#codegen).\n- [packages/oauth/\\*](packages/oauth/) — OAuth client/provider implementations and JWK helpers.\n- [packages/internal/\\*](packages/internal/) — `@atproto-labs/*` internal shared utilities (fetch, handle/identity/DID resolvers, simple-store, pipe, xrpc-utils).\n- [services/{pds,bsky,bsync,ozone}](services/) — thin runtime wrappers; the actual implementation code lives in `packages/{pds,bsky,bsync,ozone}`.\n- [lexicons/](lexicons/) — the canonical JSON Lexicon schemas for `com.atproto.*` and `tools.ozone.*` **only** (this repo is their source of truth). Other namespaces (`app.bsky.*`, `chat.bsky.*`, …) are vendored per-package from the `bsky` repo or the network — see [Codegen](#codegen). [lexicons-overrides/](lexicons-overrides/) holds local override documents shared across packages.\n- [interop-test-files/](interop-test-files/) — language-neutral protocol conformance fixtures, copied from [bluesky-social/atproto-interop-tests](https://github.com/bluesky-social/atproto-interop-tests/) (their canonical source, shared across SDKs). Don't edit unless changing protocol-level behavior; any edit must also be contributed upstream.\n\n## Common commands\n\nWhole-repo verification commands (from root):\n\n```bash\n# build:tooling → prebuild (codegen) → build:ts → build:ui\npnpm run build [--force]\n\n# TypeScript-only build & typecheck\npnpm run build:ts [--force]\n\n# force code generation (lexicons, protobuf, i18n, utils, etc.)\npnpm run codegen\n\n# style + lint\npnpm run verify\npnpm run style:fix # Avoid, prefer per-file formatting & linting\n\n# lint specific files only\npnpm exec eslint --fix <path>\n\n# format specific files only\npnpm exec prettier --write <path>\n```\n\nPer-package work — **always run from inside the package directory**, not from the root:\n\n```bash\ncd packages/<pkg>\npnpm run build\npnpm run test\n```\n\nEvery package ships a `tsconfig.build.json` (composite, with explicit `references` to its workspace deps), and nearly every package with tests adds a `tsconfig.test.json` for the test sources. A package with root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, `lingui.config.ts`, `bin.js`, …) also adds a `tsconfig.config.json` so those files are type-checked — it extends [tsconfig/config.tsconfig.json](tsconfig/config.tsconfig.json) and `include`s the package's own `./*.{ts,js,cjs,mjs}`. When you add such a file to a package that has none, create `tsconfig.config.json` and reference it from the package `tsconfig.json` (`exclude` any root file that pulls `./src` or `./tests` into the program, e.g. a `jest.setup.ts`). The root `tsconfig.json` is a project-graph aggregator only.\n\nAvoid `pnpm run style:fix` (whole-repo prettier) unless the user explicitly asks for a repo-wide formatting pass.\n\nRun the formatter/linter once the work is complete: when about to commit, or when the user says the change is done, not after every small edit.\n\n## Tests\n\nFor Ozone integration tests, verify state through existing endpoints (for example, `queryEvents` for appeal events and `queryReports` for report state). Use direct database access only for fixtures that cannot be created through an API, or for tests specifically exercising database behavior.\n\nBefore writing or extending any test, invoke the `testing` skill ([.agents/skills/testing/SKILL.md](.agents/skills/testing/SKILL.md)). It covers runner selection (vitest vs jest), file layout, and tsconfig setup. For browser-driven UI tests, or for demoing/debugging the OAuth flows or the Account Manager interface, invoke the `playwright` skill ([.agents/skills/playwright/SKILL.md](.agents/skills/playwright/SKILL.md)) instead.\n\n## Codegen\n\nThe repo-root [lexicons/](lexicons/) holds the canonical `com.atproto.*` / `tools.ozone.*` schemas **only** — this repo is their source of truth. Every other namespace (`app.bsky.*`, `chat.bsky.*`, `site.standard.*`, …) is vendored **per package**: each package lists the NSIDs it needs and where to resolve them (the root `lexicons` directory, the `bsky` GitHub repo, the network, or the shared [lexicons-overrides/](lexicons-overrides/)) in its own committed `lexicons.json`. A package's `prebuild` runs `lex install --ci` (resolving JSON into its gitignored `./lexicons`) then `lex build` (generating the gitignored `./src/lexicons` TypeScript tree that code imports). **Full how-to: the [\"Working with lexicons\"](CONTRIBUTING.md#working-with-lexicons) section of CONTRIBUTING.md and the `lex-setup` skill.**\n\n- **Add/refresh an external lexicon in a package:** `cd packages/<pkg> && pnpm lex:install <nsid>` (adds it plus transitive deps); `pnpm lex:update` refreshes installed ones.\n- **Edit a canonical lexicon:** change it under [lexicons/](lexicons/), then run `pnpm lex:update-local` from the repo root to propagate the new CID into every dependent package's `lexicons.json`. (After editing a `.proto` file instead, run `pnpm codegen` from the root.)\n\nFor working with the generated SDK, invoke the focused skills under [.agents/skills/](.agents/skills/): `lex-setup` (lexicons.json / resolvers / codegen wiring), `lex-schema` and `lex-data` (schemas and values), `lex-client` (calls out), `xrpc-server` (defining server routes), and `lexification-client` / `lexification-server` (migrating off the legacy stack). To sync `chat.bsky.*` schemas from the chat repo, use `update-chat-lexicons`.\n\n## Architecture notes\n\n- **Lexicons are the contract.** Lexicon schemas drive both client types and server route validation. Service packages don't hand-write XRPC method signatures — they import the generated definitions from their `src/lexicons/` directory (gitignored; regenerated from the package's `lexicons.json` — see [Codegen](#codegen)).\n- ([packages/pds](packages/pds)) — a single-tenant atproto server: account management, repo storage (kysely-over-sqlite), actor storage (kysely-over-postgres), email, OAuth provider, blob storage. Runtime entry point is [services/pds](services/pds); production code is in `packages/pds/src`.\n  - Outbound requests to a URL resolved from a DID document are untrusted. Make them with `ctx.safeClient(url).xrpc(…)`, never a bare `xrpc(url, …)`, which uses the global fetch and bypasses the https-only/unicast-only checks. (`ctx.proxyAgent` is a separate thing — an undici `Dispatcher` used by pipethrough.)\n- ([packages/repo](packages/repo)) — **Every** MST traversal that can visit more than one child of a node (full-tree walks, diffs, block collection) MUST call `node.markVisited(seen)` on each node it enters, with a fresh `CidSet` per traversal. Never add one that skips it. Single-path lookups are exempt. See [packages/repo/AGENTS.md](packages/repo/AGENTS.md).\n- ([packages/bsky](packages/bsky)) — read-side service for `app.bsky.*` queries (timelines, profiles, feed generators, hydration pipeline, GraphQL-like view composition). Talks to PDSes via XRPC and to `bsync` via Connect-RPC (protobuf in `packages/bsky/proto`). Runtime entry point in [services/bsky](services/bsky).\n  - `app.bsky.feed.getFeed` forwards passthrough headers to a feed generator only when its record's service DID is in `bskyFeedgenDids`; Iris routing overrides do not grant permission.\n- ([packages/bsync](packages/bsync)) — internal service for cross-AppView synchronization (mutes, notifications). Connect-RPC interface.\n- ([packages/ozone](packages/ozone)) — moderation service for `tools.ozone.*`.\n  - Design viewer queries for hundreds of millions of moderation events and tens of millions of reports. Batch hydration, bound returned rows, and check query plans against existing indexes before proposing new ones. Keep viewer projections explicit and test ownership and private-field isolation through the API.\n  - Large report-table backfills run in the daemon as bounded primary-key batches. Lock a `job_cursor` row and commit each batch; schema migrations should add nullable columns only. After legacy report writers have rolled off, run `UPDATE job_cursor SET cursor = NULL WHERE job = 'report_reporter_did_backfill'` for a catch-up sweep. Advance past rows with missing or invalid source events and log their counts so they cannot stall the worker.\n- ([packages/dev-env](packages/dev-env)) — boots a full PDS + AppView + bsync + plc + ozone constellation in-process for tests and the `make run-dev-env` REPL. Most integration tests in `pds`/`bsky`/`ozone` use it as a fixture builder.\n\n## Conventions\n\n**Code style rules live in [STYLE_GUIDE.md](./STYLE_GUIDE.md)** — imports, typing, dependencies, change scope, and formatting. Read it before writing code. The rest of this section covers repository mechanics only.\n\n- Node ≥22 runtime floor; local nvm users default to Node 24 (`.nvmrc`). CI changeset/build/verify jobs read `devEngines.runtime.version` from `package.json`; tests stay on Node 22 for compatibility, with a security-patched minimum in `.github/workflows/repo.yaml`. Use `node --enable-source-maps` for production-style runs.\n- TypeScript compilation uses the native TS7 `tsc` (the standard `typescript` package). There is no per-package `typescript` devDependency — it is hoisted at the root. Note TS7 has no stable programmatic API yet; tools needing one must pin TS6.\n- **Every package touched by a change needs a changeset entry.** Add a file under [.changeset/](.changeset/) listing each modified package with an appropriate bump level (pre-v1 breaking changes are `minor` and everything else `patch`, post-v1 `major` for breaking changes, `minor` for new public API, `patch` otherwise). Dependency-only bumps are generated automatically — don't list them by hand. Create that file with `pnpm changeset`.\n- **Never buffer an unbounded stream from outside the process.** Decoding or buffering a request/response body that did not originate locally requires an explicit size bound on the _decoded_ bytes — a wire-size cap does not bound a compressed payload. Pass `maxSize` (and a `maxSizeError` for the caller's error type) to `createDecoders` and `pipeline` the result — it bounds every decoding stage, and must be used even when there is no content-encoding (see `packages/xrpc-server/src/util.ts`) — rather than passing a decoded stream straight to `streamToNodeBuffer`.\n\n## Agent files\n\nAgent files — this `AGENTS.md`, the skills under [.agents/skills/](.agents/skills/), and any package-level equivalents — are part of the codebase and must stay in sync with it.\n\n- Keep working plans, handoffs, review reports, query-plan captures, and other session scratch files outside the repository, such as in the workspace directory containing the checkout. Do not add repository ignore rules for them. Never commit them. Before committing, inspect the staged file list for working documents; put only durable project guidance in tracked documentation.\n\n- These files capture **important architectural patterns and conventions** — the non-obvious rules, invariants, and cross-cutting decisions an expert needs that are _not_ evident from reading the code itself. They are not a changelog of behavior, and must not restate anything a reader can infer directly from the code (a route's signature, a function's arguments, what a module exports). Most changes need no update: don't document a new route, metric, migration, or test that simply re-applies an existing pattern. Reach for an edit only when a change establishes, meaningfully alters, or removes such an architectural pattern, and prefer doing it when wrapping up rather than mid-task. If you do, update the file that owns it (repo-wide → this file or [STYLE_GUIDE.md](./STYLE_GUIDE.md); scoped to one package → that package's nested `AGENTS.md`; a workflow an agent follows → the relevant skill), and delete guidance a change makes obsolete in the same pass — stale guidance is worse than none. Reviewers check for drift as a backstop (see [.github/claude-review-prompt.md](.github/claude-review-prompt.md)).\n- Keep them **as concise as possible**: only the minimal directives an expert needs. No tutorials, no restating what the code already says.\n\n## Troubleshooting\n\n- **Integration tests run stale service code.** `@atproto/dev-env` loads services through their package exports from `dist`. After changing service code, run `pnpm run build` inside that service package before rerunning its integration tests.\n- **Stale codegen.** If the build fails due to a generated file in [packages/api](packages/api) being out of date, run `pnpm run codegen && pnpm run build` from that package, then re-run the build. This is only needed there because its `prebuild` step skips codegen as a performance optimization.\n- **Codegen ran but produced stale output.** Codegen relies on `pnpm build:tooling` to build the `@atproto/lex-cli` and `@atproto/lex-builder` packages first. If you see a codegen failure, run `pnpm build:tooling` from the root, then re-run codegen.\n- **End-to-end test fails with stale infra.** If docker containers persist across test runs, reset them with `cd packages/dev-infra && docker compose down --volumes`.\n- **Nothing else worked.** `make clean` wipes every installed dependency (`node_modules`), build artifact (`dist`, `*.tsbuildinfo`), and prebuild/codegen output across all packages; follow it with `pnpm install && pnpm run build` to restore a clean state.\n","CLAUDE.md":"# CLAUDE.md\n\n@AGENTS.md\n","packages/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2FREADME.md -->","packages/api/README.md":"# ATP API\n\nThis API is a client for ATProtocol servers. It communicates using HTTP. It includes:\n\n- ✔️ APIs for ATProto and Bluesky.\n- ✔️ Validation and complete typescript types.\n- ✔️ Session management.\n- ✔️ A RichText library.\n\n> [!Note]\n>\n> We recommend using [lex](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), our type-safe SDK with Lexicon code generation, for all new projects.\n\n## Getting started\n\nFirst install the package:\n\n```sh\nyarn add @atproto/api\n```\n\nThen in your application:\n\n```typescript\nimport { Agent, CredentialSession } from '@atproto/api'\n\nconst session = new CredentialSession(new URL('https://bsky.social'))\nawait session.login(account)\nconst agent = new Agent(session)\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 `Agent`\nclass.\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 { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api'\n\n// Configure connection to the server with authentification\nconst account: AtpAgentLoginOpts = {\n  identifier: 'your.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n}\n\nasync function authenticate(account: AtpAgentLoginOpts): Promise<Agent> {\n  const session = new CredentialSession(new URL('https://example.com'))\n  await session.login(account)\n  const agent = new Agent(session)\n  return agent\n}\n\n;(async () => {\n  console.log('Authenticating...')\n  const agent = await authenticate(account)\n  console.log(`Authenticated as from: ${agent.sessionManager.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\n`OAuthSession` instance that can be used to instantiate an `Agent` (from\n`@atproto/api`). Here is an example restoring a previously saved session:\n\n```typescript\nimport { Agent } from '@atproto/api'\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 api Agent using an OAuthSession\nconst agent = new Agent(oauthSession)\n```\n\n### API calls\n\nThe agent includes methods for many common operations, including:\n\n```typescript\n// The DID of the user currently authenticated (or undefined)\nagent.did\nagent.accountDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait agent.getTimeline(params, opts)\nawait agent.getAuthorFeed(params, opts)\nawait agent.getPostThread(params, opts)\nawait agent.getPost(params)\nawait agent.getPosts(params, opts)\nawait agent.getLikes(params, opts)\nawait agent.getRepostedBy(params, opts)\nawait agent.post(record)\nawait agent.deletePost(postUri)\nawait agent.like(uri, cid)\nawait agent.deleteLike(likeUri)\nawait agent.repost(uri, cid)\nawait agent.deleteRepost(repostUri)\nawait agent.uploadBlob(data, opts)\n\n// Social graph\nawait agent.getFollows(params, opts)\nawait agent.getFollowers(params, opts)\nawait agent.follow(did)\nawait agent.deleteFollow(followUri)\n\n// Actors\nawait agent.getProfile(params, opts)\nawait agent.upsertProfile(updateFn)\nawait agent.getProfiles(params, opts)\nawait agent.getSuggestions(params, opts)\nawait agent.searchActors(params, opts)\nawait agent.searchActorsTypeahead(params, opts)\nawait agent.mute(did)\nawait agent.unmute(did)\nawait agent.muteModList(listUri)\nawait agent.unmuteModList(listUri)\nawait agent.blockModList(listUri)\nawait agent.unblockModList(listUri)\n\n// Notifications\nawait agent.listNotifications(params, opts)\nawait agent.countUnreadNotifications(params, opts)\nawait agent.updateSeenNotifications()\n\n// Identity\nawait agent.resolveHandle(params, opts)\nawait agent.updateHandle(params, opts)\n\n// Legacy: Session management should be performed through the SessionManager\n// rather than the Agent instance.\nif (agent instanceof AtpAgent) {\n  // AtpAgent instances support using different sessions during their lifetime\n  await agent.createAccount({ ... }) // session a\n  await agent.login({ ... }) // session b\n  await agent.resumeSession(savedSession) // session c\n}\n```\n\n### Validation and types\n\nThe package includes a complete types system which includes validation and type-guards. For example, to validate a post record:\n\n```typescript\nimport { AppBskyFeedPost } from '@atproto/api'\n\nconst post = {...}\nif (AppBskyFeedPost.isRecord(post)) {\n  // typescript now recognizes `post` as a AppBskyFeedPost.Record\n  // however -- we still need to validate it\n  const res = AppBskyFeedPost.validateRecord(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 { RichText } from '@atproto/api'\n\n// creating richtext\nconst rt = new RichText({\n  text: 'Hello @alice.com, check out this link: https://example.com',\n})\nawait rt.detectFacets(agent) // automatically detects mentions and links\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: new Date().toISOString(),\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 { moderatePost } from '@atproto/api'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: agent.session.did,\n  moderationPrefs: 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 include in feeds\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 methods above are convenience wrappers. It covers most but not all available methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can use them on the agent as well:\n\n```typescript\nconst res1 = await agent.com.atproto.repo.createRecord({\n  did: alice.did,\n  collection: 'app.bsky.feed.post',\n  record: {\n    $type: 'app.bsky.feed.post',\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n})\nconst res2 = await agent.com.atproto.repo.listRecords({\n  repo: alice.did,\n  collection: 'app.bsky.feed.post',\n})\n\nconst res3 = await agent.app.bsky.feed.post.create(\n  { repo: alice.did },\n  {\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n)\nconst res4 = await agent.app.bsky.feed.post.list({ repo: alice.did })\n```\n\n### Non-browser configuration\n\nIf your environment doesn't have a built-in `fetch` implementation, you'll need\nto provide one. This will typically be done through a polyfill.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation, you can do so by\ninstantiating the sessionManager with a custom fetch implementation:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst myFetch = (input: RequestInfo | URL, init?: RequestInit) => {\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 agent = new AtpAgent({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\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/api/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  moderationPrefs: {\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: string\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: AppBskyActorDefs.MutedWord[]\n  hiddenPosts: string[]\n}\n\nexport interface ModerationOpts {\n  userDid: string | 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 `agent.getPreferences()` method:\n\n```typescript\nconst prefs = await agent.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 this static function, though the default is to use Bluesky's moderation:\n\n```typescript\nBskyAgent.configure({\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\nUsers may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed.\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 definition 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.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours).\n\nHere is how to do this:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst agent = new AtpAgent({ service: 'https://example.com' })\n// assume `agent` is a signed in session\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\nmoderatePost(post, {\n  userDid: agent.session.did,\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  moderateFeedGen,\n  moderateUserList,\n  moderateLabeler,\n} from '@atproto/api'\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## 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 `Atproto-Proxy` header:\n\n```typescript\nagent\n  .withProxy('atproto_labeler', 'did:web:my-labeler.com')\n  .createModerationReport({\n    reasonType: 'com.atproto.moderation.defs#reasonViolation',\n    reason: 'They were being such a jerk to me!',\n    subject: { did: 'did:web:bob.com' },\n  })\n```\n","packages/aws/README.md":"# AWS KMS\n\nA Keypair-compatible wrapper for AWS KMS.\n","packages/bsky/AGENTS.md":"# AppView hydration\n\n- Endpoint-specific viewer block checks must honor `HydrateCtx.skipViewerBlocks` for the trusted moderation service, while preserving viewer block metadata and mute filtering.\n- In `hydration/external.ts`, keep shared types above `ExternalHydrator`; place helpers and their specific types below the class.\n- `ExternalHydrator` generic record lookups decode record bodies without collection-specific validation. Validate against the relevant Lexicon before using typed fields; do not apply parse-mode defaults to stored records.\n- `getRecordsByURI`/`getRecordsByRef` split input into sequential requests of at most `MAX_RECORDS_PER_REQUEST` (the dataplane rejects more than 100 URIs), pairing results positionally per request. Any failed request rejects the whole call; never return partially fetched batches. Bound requests there, not in callers or traversal.\n- `Views.externalRecordView` consumes `HydrationState.externalRecords` (latest versions), `externalRecordBacklinks` (target URI → available source URI samples), and `externalRecordBacklinkCounts`. Hydrate referenced publications, gallery item/photo records, backlink source records, labels, and profiles before building views. Counts are independent of the sampled previews.\n- External view builders keep strict Lexicon validation (`$matches`). Handle third-party records that violate their own published lexicon with a minimal compatibility override (see `lexicons-overrides/README.md`), not by loosening view validation.\n- Streamplace uses a declared HTTP(S) URL or falls back to its AT URI. A stream is active only without `endedAt` and with a `lastSeenAt` within `LIVESTREAM_HEARTBEAT_WINDOW_MS` (2 minutes) of `now`, on either side; record `idleTimeoutSeconds` and `createdAt` are ignored.\n- Atmosphere Explore uses each worker item’s `canonicalUri` as its sole hydration/view root; a missing or invalid root skips the item with no fallback or backfill. With the `AtmosphereExploreArticlesIrisEnable` gate off, worker groups also supply articles; with it on, up to 10 Iris `site.standard.document` roots (fetched concurrently with the worker) are the exclusive article source when the call succeeds with a nonempty articles list: worker articles are ignored and worker location/topic selection does not apply to them. Every list is capped at 10: selected record-backed lists (including Iris articles) are shuffled before hydration and presentation preserves that order; apps are capped at 10 in worker order. Livestream editorial windows constrain visibility before selection and capping without replacing heartbeat-based liveness. App-profile hydration is batched for the selected apps under the same request context, and upstream-only metadata stays out of public cards. Worker ingestion is best-effort per item: malformed banners, groups, and app cards are skipped without failing siblings or the request; only the first 100 entries per worker section are examined. Call Iris directly with request-scoped headers and abort signals, explicitly enabling XRPC response validation even outside debug mode; do not add a separate response parser. Keep article-root validation and the 10-item cap. A successful empty Iris list falls back to worker-curated articles and retains Iris's rec ID; a nonempty Iris list fully removed by root filtering or hydration does not trigger curated fallback. Catch Iris request and response-validation failures (rethrowing request aborts): log a warning and fall back to worker-curated articles with no rec ID; worker failures stay fatal, and the gate-on missing-Iris-client 501 is unchanged. The worker fetch retains its decoded-byte limit and deadline. Each list is an `{ items, recIdStr? }` section; `articles.recIdStr` comes from a successful Iris response, including the empty-list worker fallback, rather than identifying the source of the returned articles. Keep root validation in `getAtmosphereExploreTab.ts`; one shared hydration traversal discovers publications and other dependencies.\n- Exact-ref hydration maps are keyed by the branded `ExactRecordKey` from `genericRecordKey({ uri, cid })`, not URI alone; `parseGenericRecordKey` accepts only those keys and throws on malformed ones. Unavailable records are `null`, and takedowns are excluded unless explicitly requested.\n- Preserve `truncated` from actor backlink lookups: an empty target entry in a truncated batch does not prove the actor has no links.\n- Saved-post hydration selects one CID-pinned root per embed, regardless of collection: the first authored ref, except a legacy publication-first pair may select a document at index 1. Never inspect later refs or fall back from an unavailable selected root. Batch selected roots across post layers through the generic exact-ref traversal. A document's publication is a discovered latest dependency, not a second authored pin; discarded refs must not affect rendering or moderation. A publication selected as the root remains exact.\n- Pass all external URIs to `Hydrator.hydrateExternalViewDependencies`; dispatch by URI collection and validate records inside it. Batch new dependencies through `hydrateEmbedExternalViewFromUris`, sharing a `seenRecordKeys` set (including null lookups) across nested passes. Follow only complete DID-based record URIs.\n- `hydrateEmbedExternalViewFromUris` nulls each generic record (in `externalRecords` and `externalRecordsByRef`, keeping keys) that has an actionable takedown label (unless `includeTakedowns`) or an explicitly `null` owner actor, before dependency expansion. Hiding is per record: never propagate it to targets or dependencies, and leave backlink counts and samples unadjusted.\n- Dependency expansion is bounded by collection-typed edges and per-source sample limits, not a page-wide allowance. Validate direct references and backlink results with `recordUriOf`: complete DID record URI, expected collection, no query/fragment. A source query must never admit a URI of another collection, which could turn a terminal preview into another expanding root. Keep dependencies batched/deduped across posts; direct-only passes issue no backlink or count calls.\n- External hydration traversals share a `seenRecordKeys` set that both wrappers filter against at entry by `genericRecordKey(ref)` (bare URI for latest, `uri@cid` for exact), so each lookup runs once per traversal (null results stay seen). `MAX_EXTERNAL_HYDRATION_PASSES` hard-caps chained batches (root is pass 1; `hydrationPass` is threaded through both methods and never reset). Physical request chunks within a batch consume no passes. At the cap, keep partial state, leave skipped URIs unseen, and warn.\n- External-view counts, backlink pages, and nested dependency batches are best-effort: log failures and leave their state absent (a successful empty page is `[]`), and builders consume partial state, so galleries may be empty. Root fetch errors still propagate. Each recovered failure sets the tracker's `partial` flag, so the traversal reports `partial` unless it was `capped`, which takes precedence. Don't add failure-policy machinery.\n- Thread the optional `hydrationMetrics` tracker through every nested external hydration call (wrappers and `hydrateExternalViewDependencies`). Only the call without one owns the traversal and reports `events.externalHydrationTraversal` once; omitting it in a nested call double-reports. Count lookups at batch scheduling, never from map or seen-set sizes, and keep totals out of metric attributes. `batches` counts logical passes, not RPCs. The reported endpoint is `HydrateCtx.xrpcEndpoint`, an NSID-formatted string that handlers set from their method's `$lxm`. Discovery counts each inspected ref as resolved if available after moderation; the first discovery call's refs are roots (tracked by a flag, not pass number), later ones dependencies.\n- `hydrateEmbedExternalViewFromUris` and `hydrateEmbedExternalViewFromRefs` are thin wrappers over one pipeline and one collection dispatch. Latest lookups are identified by URI (`externalRecords`); exact versions by `genericRecordKey({ uri, cid })` (`externalRecordsByRef`). Both identities share `seenRecordKeys`, and one never implies the other; a latest fetch does not mark its returned version as an exact lookup. Dependency discovery takes `ItemRef[]` and inspects every pinned version; nested dependencies are authored URIs and always go through the URI wrapper. A successful lookup with the same URI and CID may replace an unavailable pinned version; a different CID never satisfies it.\n- External hydration takes `ExternalHydrationOptions` (`hydration`, plus the shared `seenRecordKeys`, `hydrationPass`, `hydrationMetrics`). `'basic'` keeps renderable content (including gallery item samples and photos, publication discovery), owner profiles/labels/moderation and backlink counts, but skips social-preview samples and their records, participant profiles and labels. `'detailed'` (default) adds those previews. Collection rules in `discoverExternalViewDependencies` decide this, never callsites; counts are scheduled for every target even with no sample queries. One level applies to a whole traversal: pass it through every nested call. `hydratePosts` (feeds) uses basic. The level never changes identity, pin or latest-dependency semantics.\n\n# External embed validation utilities\n\n- `util/external/standard-site.ts` (`getStandardSiteRecordsForUri`, `validateStandardSiteRecordsForUrl`) and `util/external/index.ts` (`isExternalRecordUrlSafe`) are pure and synchronous over caller-supplied hydrated records, URLs, and a domain list: no I/O, config reads, mutation, or view construction.\n\n# `app.bsky.embed.getEmbedExternalView`\n\n- The handler normalizes the request to one record URI before hydration: an explicit `canonicalUri` always wins (ignoring `uris`, with no fallback); otherwise the legacy `uris` input selects the first `site.standard.document`, else the first `site.standard.publication`. Only the one URI is hydrated; dependencies come from existing hydration.\n- Validate before building any view: Standard.site records via `getStandardSiteRecordsForUri` + `validateStandardSiteRecordsForUrl`, other records via `isExternalRecordUrlSafe(url, cfg.externalRecordsSafeDomains)`. Renderability is never the trust test.\n- Validated records return `data` and `associatedRefs`/`associatedRecords`; failed validation returns only `unverifiedAssociatedRefs`/`unverifiedAssociatedRecords` and builds no view. The legacy `view` is returned for Standard.site records only. Both outputs and the legacy `view.external.associatedRefs` use the same index-aligned persisted subset, canonical first.\n- Render-time hydration and persistence are separate. Hydration may include engagement records (recommendations, subscriptions, favorites) that back `likers`/`subscribers` previews; that is intentional. `collectAssociations` persists the canonical record when available (whatever its collection) plus only the extra collections opted in by `PERSISTED_DEPENDENCY_COLLECTIONS` (document → publication; gallery → gallery item and photo), to keep post records slim. It is not an engagement blacklist. A new persisted dependency collection needs an explicit entry; do not change hydration or views for this.\n","packages/bsky/README.md":"# @atproto/bsky: Bluesky AppView Service\n\nTypeScript implementation of the `app.bsky` Lexicons backing the https://bsky.app microblogging application.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsky)](https://www.npmjs.com/package/@atproto/bsky)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/bsky/src/feature-gates/README.md":"# Feature Gates\n\nA thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag\nevaluation and experiment tracking.\n\n## Usage\n\nFeature gates are accessible on `HydrationCtx` as `features`. A default \"scope\"\nis defined in `Hydrator.createContext`, which is called by every request\nhandler. The default scope supplies anonymous `deviceId` and `sessionId`\nidentifiers for targeting unauthenticated users.\n\nFeature gates can be checked via the following methods.\n\n```typescript\n// check a single gate\nconst enabled = ctx.features.checkGate(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n\n// check multiple gates\nconst gates = ctx.features.checkGates([\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n  ctx.features.Gate.SomeOtherGate,\n])\nconst enabled = gates.get(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n```\n\n### User Context\n\nTo accurately gate features by user, we need additional context about that user.\nFor most use cases, we get this data from within the request handlers.\n\nHere, the `features` client will be scoped to the current user, allowing for\naccurate gate checks throughout the request lifecycle.\n\n```typescript\nconst features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(\n  ctx.featureGatesClient.parseUserContextFromHandler({\n    viewer,\n    req,\n  }),\n)\n\nconst hydrateCtx = await ctx.hydrator.createContext({\n  labelers,\n  viewer,\n  features,\n})\n```\n\n> [!NOTE]\n> Although `ctx.featureGatesClient` also has a `checkGates` method, the\n> intention is to use the `ScopedFeatureGatesClient` returned by `scope()` for\n> gate checks within the application.\n\nYou can also pass the user context directly to `checkGate` or `checkGates`. This\noverrides any default scope set on the client, allowing for flexibility in cases\nwhere user context is only available at the point of gate evaluation.\n\n```typescript\nconst enabled = ctx.featureGatesClient.checkGate(\n  ctx.featureGatesClient.Gate.ImageFeatureEnabled,\n  { did: imageAuthor.did },\n)\n```\n\n### Adding Gates\n\nSee `gates.ts` and add them to the enum. You can optionally prevent metrics from\nbeing emitted for a gate by adding the gate to the `IGNORE_METRICS_FOR_GATES`\nset.\n\n## Metrics\n\nCheck out the Growthbook docs for more info here. Basically, every feature eval\nfires the `onFeatureUsage` callback. Any feature that is part of an experiment\n_will also fire the `trackingCallback` callback._\n\nFor some use cases, this can be noisy and/or performance intensive, so make use\nof `IGNORE_METRICS_FOR_GATES` to silence metrics for specific gates.\n","packages/bsync/README.md":"# @atproto/bsync: Synchronizing Service for the Bluesky AppView\n\nThis is an optional service that may be used to synchronize certain state between otherwise independent AppViews.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsync)](https://www.npmjs.com/package/@atproto/bsync)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/common-web/README.md":"# @atproto/common-web\n\nShared TypeScript code for other `@atproto/*` packages, which is web-friendly (runs in-browser).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common-web)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/common/README.md":"# @atproto/common\n\nShared TypeScript code for other `@atproto/*` packages. This package is oriented towards writing servers, and is not designed to be browser-compatible.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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/crypto/README.md":"# @atproto/crypto\n\nTypeScript library providing basic cryptographic helpers as needed in [atproto](https://atproto.com).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/crypto)](https://www.npmjs.com/package/@atproto/crypto)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\nThis package implements the two currently supported cryptographic systems:\n\n- P-256 elliptic curve: aka \"NIST P-256\", aka secp256r1 (note the r), aka prime256v1\n- K-256 elliptic curve: aka \"NIST K-256\", aka secp256k1 (note the k)\n\nThe details of cryptography in atproto are described in [the specification](https://atproto.com/specs/cryptography). This includes string encodings, validity of \"low-S\" signatures, byte representation \"compression\", hashing, and more.\n\n## Usage\n\n```typescript\nimport { verifySignature, Secp256k1Keypair, P256Keypair } from '@atproto/crypto'\n\n// generate a new random K-256 private key\nconst keypair = await Secp256k1Keypair.create({ exportable: true })\n\n// sign binary data, resulting signature bytes.\n// SHA-256 hash of data is what actually gets signed.\n// signature output is often base64-encoded.\nconst data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])\nconst sig = await keypair.sign(data)\n\n// serialize the public key as a did:key string, which includes key type metadata\nconst pubDidKey = keypair.did()\nconsole.log(pubDidKey)\n\n// output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38'\n\n// verify signature using public key\nconst ok = verifySignature(pubDidKey, data, sig)\nif (!ok) {\n  throw new Error('Uh oh, something is fishy')\n} else {\n  console.log('Success')\n}\n```\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/dev-env/README.md":"# @atproto/dev-env: Local Developer Environment\n\nA command-line application for developers to construct and manage development environments.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/dev-env)](https://www.npmjs.com/package/@atproto/dev-env)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\n## REPL API\n\nThe following methods are available in the REPL.\n\n### `status()`\n\nList the currently active servers.\n\n### `startPds(port?: number)`\n\nCreate a new PDS instance. Data is stored in memory.\n\n### `stop(port: number)`\n\nStop the server at the given port.\n\n### `mkuser(handle: string, pdsPort?: number)`\n\nCreate a new user.\n\n### `user(handle: string): ServiceClient`\n\nGet the `ServiceClient` for the given user.\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/dev-infra/README.md":"# dev-infra\n\nHelpers for working with postgres and redis locally. Previously known as `pg`.\n\n## Usage\n\n### `with-test-db.sh`\n\nThis script allows you to run any command with a fresh, ephemeral/single-use postgres database available. When the script starts a Dockerized postgres container starts-up, and when the script completes that container is removed.\n\nThe environment variable `DB_POSTGRES_URL` will be set with a connection string that can be used to connect to the database. The [`PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) that are recognized by libpq (i.e. used by the `psql` client) are also set.\n\n**Example**\n\n```\n$ ./with-test-db.sh psql -c 'select 1;'\n[+] Running 1/1\n ⠿ Container pg-db_test-1  Healthy                                                           1.8s\n\n ?column?\n----------\n        1\n(1 row)\n\n\n[+] Running 1/1\n ⠿ Container pg-db_test-1  Stopped                                                           0.1s\nGoing to remove pg-db_test-1\n[+] Running 1/0\n ⠿ Container pg-db_test-1  Removed\n```\n\n### `with-redis-and-test-db.sh`\n\nThis script is similar to `with-test-db.sh`, but in addition to an ephemeral/single-use postgres database it also provides a single-use redis instance. When the script starts, Dockerized postgres and redis containers start-up, and when the script completes the containers are removed.\n\nThe environment variables `DB_POSTGRES_URL` and `REDIS_HOST` will be set with a connection strings that can be used to connect to postgres and redis respectively.\n\n### `docker-compose.yaml`\n\nThe Docker compose file can be used to run containerized versions of postgres either for single use (as is used by `with-test-db.sh`), or for longer-term use. These are setup as separate services named `db_test` and `db` respectively. In both cases the database is available on the host machine's `localhost` and credentials are:\n\n- Username: pg\n- Password: password\n\nHowever, each service uses a different port, documented below, to avoid conflicts.\n\n#### `db_test` service for single use\n\nThe single-use `db_test` service does not have any persistent storage. When the container is removed, data in the database disappears with it.\n\nThis service runs on port `5433`.\n\n```\n$ docker compose up db_test   # start container\n$ docker compose stop db_test # stop container\n$ docker compose rm db_test   # remove container\n```\n\n#### `db` service for persistent use\n\nThe `db` service has persistent storage on the host machine managed by Docker under a volume named `pg_atp_db`. When the container is removed, data in the database will remain on the host machine. In order to start fresh, you would need to remove the volume.\n\nThis service runs on port `5432`.\n\n```\n$ docker compose up db -d    # start container\n$ docker compose stop db     # stop container\n$ docker compose rm db       # remove container\n$ docker volume rm pg_atp_db # remove volume\n```\n\n#### `redis_test` service for single use\n\nThe single-use `redis_test` service does not have any persistent storage. When the container is removed, the data in redis disappears with it.\n\nThis service runs on port `6380`.\n\n#### `redis` service for persistent use\n\nThe `redis` service has persistent storage on the host machine managed by Docker under a volume named `atp_redis`. When the container is removed, the data in redis will remain on the host machine. In order to start fresh, you would need to remove the volume.\n\nThis service runs on port `6379`.\n","packages/identity/README.md":"# @atproto/identity\n\nTypeScript library for decentralized identities in [atproto](https://atproto.com) using DIDs and handles\n\n[![NPM](https://img.shields.io/npm/v/@atproto/identity)](https://www.npmjs.com/package/@atproto/identity)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\n## Example\n\nResolving a Handle and verifying against DID document:\n\n```typescript\nconst didres = new DidResolver({})\nconst hdlres = new HandleResolver({})\n\nconst handle = 'atproto.com'\nconst did = await hdlres.resolve(handle)\n\nif (did == undefined) {\n  throw new Error('expected handle to resolve')\n}\nconsole.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz\n\nconst doc = await didres.resolve(did)\nconsole.log(doc)\n\n// additional resolutions of same DID will be cached for some time, unless forceRefresh flag is used\nconst doc2 = await didres.resolve(did, true)\n\n// helper methods use the same cache\nconst data = await didres.resolveAtprotoData(did)\n\nif (data.handle != handle) {\n  throw new Error('invalid handle (did not match DID document)')\n}\n```\n\n## SSRF protection\n\nHTTP identity resolution — the handle `/.well-known/atproto-did` endpoint,\n`did:plc`, and `did:web` — is routed through an SSRF-protected fetch by\ndefault. Private IPs, IP-literal hosts, `http:` URLs, custom ports, and a\nhandful of forbidden domain names are refused.\n\nTo resolve identities against localhost or another private network — a test\nsuite or a local dev stack — pass your own `fetch`. A supplied `fetch` is\nused as-is and is never re-wrapped:\n\n```typescript\nconst didres = new IdResolver({ plcUrl, fetch: globalThis.fetch })\n```\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/internal/fetch-node/README.md":"# Node.js fetch utilities\n\nFetch helpers for Node.js 22+ that help protect services when fetching URLs supplied by users.\n\n## Usage\n\nCreate a fetch wrapper once and reuse it:\n\n```js\nimport { safeFetchWrap } from '@atproto-labs/fetch-node'\n\nconst safeFetch = safeFetchWrap()\nconst response = await safeFetch('https://api.github.com/repos/nodejs/node', {\n  redirect: 'follow',\n})\nconst data = await response.json()\n```\n\nBy default, `safeFetchWrap` allows HTTPS on port 443, blocks private and reserved IP addresses and a list of forbidden domains, sets a 10-second timeout, and limits response bodies to 512 KiB. Destination checks also apply to redirects, and IP addresses are checked when connecting.\n\nPass `redirect` explicitly on each call: use `'follow'`, `'manual'`, or `'error'`. Configure the wrapper with options such as `timeout` (milliseconds), `responseMaxSize` (bytes), and `forbiddenDomainNames`. If you supply a custom `fetch`, it must honor Undici's `dispatcher` option; do not pass a separate dispatcher on individual calls.\n\n## Other helpers\n\n- `unicastFetchWrap()` restricts destinations to public IP addresses, including redirects, without adding the HTTPS, timeout, or response-size rules.\n- `safeDispatchFetchWrap({ checkUrl, lookup })` lets you supply your own destination checks. `checkUrl` receives the origin of each request, including redirects, and returns a reason string to block it or `undefined` to allow it.\n- `unicastLookup` checks DNS results before connecting. `isUnicastIpHostname(hostname)` checks a literal IP address and returns `undefined` for a domain name.\n- This package also exports the helpers from `@atproto-labs/fetch` for working with requests, responses, errors, and fetch wrappers.\n\n## Temporary eMudhra TLS mitigation\n\n`emudhra-preload` blocks HTTPS connections that use any of the eight intermediate keys affected by the [eMudhra compromise](https://emudhra.we-have-a-problem.com/), as listed in [nodejs/node#66871](https://github.com/nodejs/node/pull/66871). It keeps the existing trusted roots, so other intermediates under those roots still work. Remove it once you deploy Node.js with the fix.\n\nNode supports multiple `--import` flags. Load this before tracing and application code:\n\n```sh\nnode --import=@atproto-labs/fetch-node/emudhra-preload --import=@atproto/pds/telemetry service.js\n```\n\nOr copy [`src/emudhra-preload.mjs`](src/emudhra-preload.mjs) into your image and load it directly. It needs no other files or packages:\n\n```sh\nnode --import=/opt/mitigations/emudhra-preload.mjs --import=./tracing.mjs service.js\n```\n\nYou can also add `--import=/opt/mitigations/emudhra-preload.mjs` to `NODE_OPTIONS`. Restart every process and discard any TLS sessions saved before the preload was installed. Resumed sessions skip the check.\n\nThe preload works with Node's HTTPS client, built-in `fetch()`, and Undici's standard connectors, including custom CAs and hostname checks. It wraps `tls.connect()` and adds the key check to the client's existing certificate check. Blocked connections fail with `CERT_REVOKED` before any HTTP request data is sent.\n\nIt checks keys during full TLS handshakes and caches up to 256 certificate results to avoid repeated work. Normal certificate validation still runs. Reusing a connection or resuming a checked session adds no key-check work.\n\nLimits:\n\n- Keep certificate verification enabled: do not set `rejectUnauthorized: false` or `NODE_TLS_REJECT_UNAUTHORIZED=0`.\n- Clients must use `tls.connect()` after the preload runs. Other TLS libraries, direct `TLSSocket` use, and incoming client certificates are not covered. `--require` runs before `--import`, so avoid opening connections or saving a reference to `tls.connect` there.\n- Workers and forked processes normally inherit the preload. Keep it enabled if you override `execArgv` or `NODE_OPTIONS`.\n- A server can also be blocked if it sends an unused certificate with one of the eight keys. Unlike Node's native fix, this preload cannot check only the certificate path OpenSSL chose.\n","packages/internal/handle-resolver/README.md":"# Universal Handle Resolver implementation for ATPROTO\n\nThis package provides a handle resolver implementation for ATPROTO. It is used\nto resolve handles to their corresponding DID.\n\nThis package is meant to be used in any JavaScript environment that support the\n`fetch()` function. Because APTORO handle resolution requires DNS resolution,\nyou will need to provide your own DNS resolution function when using this\npackage.\n\nThere are two main classes in this package:\n\n- `AtprotoHandleResolver` This implements the official ATPROTO handle resolution\n  algorithm (and requires a DNS resolver).\n- `AppViewHandleResolver` This uses HTTP requests to the Bluesky AppView\n  (bsky.app) to provide handle resolution.\n\n## Usage\n\n### From a front-end app\n\nSince the ATPROTO handle resolution algorithm requires DNS resolution, and the\nbrowser does not provide a built-in DNS resolver, this package offers two\noptions:\n\n- Delegate handle resolution to an AppView (`AppViewHandleResolver`). This is\n  the recommended approach for front-end apps.\n- Use a DNS-over-HTTPS (DoH) server (`DohHandleResolver`). Prefer this method\n  if you don't own an AppView and already have a DoH server that you trust.\n\nUsing an AppView:\n\n> [!CAUTION]\n> Use the Bluesky owned AppView (`https://api.bsky.app/`), or PDS\n> (`https://bsky.social/`), at your own risk. Using these servers in a\n> third-party application might expose your users' data (IP address) to Bluesky.\n> Bluesky might log the data sent to it when your app is resolving handles.\n> Bluesky might also change the API, or terms or use, at any time without\n> notice. Make sure you are compliant with the Bluesky terms of use as well as\n> any laws and regulations that apply to your use case.\n\n```ts\nimport { AppViewHandleResolver } from '@atproto-labs/handle-resolver'\n\nconst resolver = new AppViewHandleResolver({\n  service: 'https://my-app-view.com/',\n})\nconst did = await resolver.resolve('my-handle.bsky.social')\n```\n\nUsing DNS-over-HTTPS (DoH) for DNS resolution:\n\n> [!CAUTION]\n> Using a DoH server that you don't own might expose your users' data to\n> the DoH server provider. The DoH server provider might log the data sent to it\n> by your app, allowing them to track which handles are being resolved by your\n> users. In the browser, it is recommended to use a DoH server that you own and\n> control. Or to implement your own AppView and use the `AppViewHandleResolver`\n> class.\n\n> [!NOTE]\n> Using the `DohHandleResolver` requires a DNS-over-HTTPS server that\n> supports the DNS-over-HTTPS protocol with \"application/dns-json\" responses.\n\n```ts\nimport { DohHandleResolver } from '@atproto-labs/handle-resolver'\n\n// Also works with 'https://cloudflare-dns.com/dns-query'\nconst resolver = new DohHandleResolver('https://dns.google/resolve', {\n  // Optional: Custom fetch function that will be used both for DNS resolution\n  // and well-known resolution.\n  fetch: globalThis.fetch.bind(globalThis),\n})\n\nconst did = await resolver.resolve('my-handle.bsky.social')\n```\n\n### From a Node.js app\n\n> [!NOTE]\n> On a Node.js backend, you will probably want to use the\n> \"@atproto-labs/handle-resolver-node\" package. The example below applies to\n> Node.js code running on a user's machine (e.g. through Electron).\n\n```ts\nimport { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'\nimport { resolveTxt } from 'node:dns/promises'\n\nconst resolver = new AtprotoHandleResolver({\n  // Optional: Custom fetch function (used for well-known resolution)\n  fetch: globalThis.fetch.bind(globalThis),\n\n  resolveTxt: async (domain: string) =>\n    resolveTxt(domain).then((chunks) => chunks.join('')),\n})\n```\n\n### Caching\n\nUsing a default, in-memory cache, in which items expire after 10 minutes:\n\n```ts\nimport {\n  AppViewHandleResolver,\n  CachedHandleResolver,\n  HandleResolver,\n  HandleCache,\n} from '@atproto-labs/handle-resolver'\n\n// See previous examples for creating a resolver\ndeclare const sourceResolver: HandleResolver\n\nconst resolver = new CachedHandleResolver(sourceResolver)\nconst did = await resolver.resolve('my-handle.bsky.social')\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\n```\n\nUsing a custom cache:\n\n```ts\nimport {\n  AppViewHandleResolver,\n  CachedHandleResolver,\n  HandleResolver,\n  HandleCache,\n} from '@atproto-labs/handle-resolver'\n\n// See previous examples for creating a resolver\ndeclare const sourceResolver: HandleResolver\n\nconst cache: HandleCache = {\n  set(handle, did): Promise<void> {\n    /* TODO */\n  },\n  get(handle): Promise<undefined | string> {\n    /* TODO */\n  },\n  del(handle): Promise<void> {\n    /* TODO */\n  },\n}\n\nconst resolver = new CachedHandleResolver(sourceResolver, cache)\nconst did = await resolver.resolve('my-handle.bsky.social')\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\n```\n\n### Observing resolution errors\n\nBy default, `WellKnownHandleResolver` (used internally by `AtprotoHandleResolver`\nand `AtprotoHandleResolverNode`) returns `null` for non-abort errors,\nindistinguishable from \"no `.well-known/atproto-did` endpoint exists\". That is\ncorrect for the resolver contract, but it hides operational signals such as 5xx\nupstream errors, SSRF blocks, redirect loops, or transient network failures.\n\nTo log or surface those failures, pass an `onError` handler when constructing the\nresolver:\n\n```ts\nconst resolver = new AtprotoHandleResolverNode({\n  onError: (err, { resolver, handle }) => {\n    console.warn(\n      `[handle-resolver:${resolver}] ${handle} failed to resolve:`,\n      err,\n    )\n  },\n})\n```\n\nBecause the handler is set on the instance, it also fires for resolution done on\nyour behalf, which a per-call option cannot reach. For example, an OAuth client\nyou hand the resolver to:\n\n```ts\nconst client = new NodeOAuthClient({\n  handleResolver: new AtprotoHandleResolverNode({ onError: logHandleError }),\n  // ...\n})\n```\n\nThe handler is observational only: the resolver still returns `null` on failure,\nso existing behavior does not change. Use it to:\n\n- Log resolution failures for debugging or alerting.\n- Surface specific failures to end users (e.g. distinguishing a misconfigured\n  custom domain from a typo'd handle).\n- Emit telemetry for handle-host operators.\n","packages/internal/opentelemetry-node/README.md":"# @atproto-labs/opentelemetry-node\n\nOpenTelemetry bootstrap for atproto Node.js services.\n\nThis package wires up the OpenTelemetry Node SDK from the conventional `OTEL_*`\nenvironment variables, so a service only has to supply its identity and the list\nof instrumentations it wants.\n\nTelemetry is **opt-in via the exporter endpoint**: the SDK stays disabled until an\nOTLP endpoint is configured (`OTEL_EXPORTER_OTLP_ENDPOINT` or a signal-specific\nvariant). `OTEL_SDK_DISABLED` remains a kill switch.\n\n## Configuration\n\nEverything beyond the service identity and instrumentation list is driven by the\nconventional `OTEL_*` environment variables. See the OpenTelemetry docs for the\nfull set:\n\n- [General SDK configuration](https://opentelemetry.io/docs/languages/sdk-configuration/general/)\n- [OTLP exporter configuration](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/)\n\n## Usage\n\nLoad it before any instrumented module — typically through Node's `--import` flag:\n\n```ts\nimport { setup } from '@atproto-labs/opentelemetry-node'\nimport { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from '@atproto-labs/opentelemetry-node/conventions'\nimport { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'\nimport pkg from './package.json' with { type: 'json' }\n\nawait setup(() => ({\n  name: pkg.name,\n  version: pkg.version,\n  // Optional: override or extend the default resource attributes.\n  defaultResourceAttributes: { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: 'staging' },\n  // Service-specific instrumentations. The instrumentations common to atproto\n  // services are always registered in addition to these.\n  instrumentations: [new IORedisInstrumentation()],\n}))\n```\n\n`setup()` takes a thunk so nothing is constructed when telemetry is disabled: the\noptions — and therefore the instrumentations — are only built once an OTLP endpoint\nis configured.\n\n### XRPC routes on metrics\n\nPass the known XRPC methods the service may answer (including the ones it\nproxies) as `xrpcMethods`. This allows to set the `http.route` attribute\ncorrectly for these methods in the metrics. When `xrpcMethods` is omitted, the\n`http.route` of the metrics is left as set by the Express instrumentation.\n\n```ts\n// @NOTE **do not** import runtime code here (like `ids` from\n// `@atproto/lex-cli`'s `./lexicon/lexicons.js`) as telemetry setup must run\n// before any instrumented module. Importing from the `@atproto/lex` manifest\n// (JSON) is fine.\n\nimport { setup } from '@atproto-labs/opentelemetry-node'\nimport pkg from './package.json' with { type: 'json' }\nimport lex from './lexicons.json' with { type: 'json' }\n\nawait setup(() => ({\n  name: pkg.name,\n  version: pkg.version,\n  xrpcMethods: lex.lexicons\n    // @NOTE we only care about methods, which, by convention, have a verb\n    // followed by a capitalized noun (e.g., `getFeed`). Reducing the list\n    // helps keep the attribute low-cardinality.\n    .filter((v) => /[A-Z]/.test(v)),\n}))\n```\n\nThis sets the `http.route` attribute of the HTTP server duration metric to\n`/xrpc/<nsid>` for these methods, and to `/xrpc/{unknown}` for any other NSID.\nThe list is what keeps the attribute low-cardinality: any client\ncan make up an NSID. Span names aren't affected.\n\nInstrumentations you supply must be built on the same\n`@opentelemetry/instrumentation` as this package, or ESM imports of the modules\nthey target can't be instrumented: `setup()` then rejects with an\n`InstrumentationMismatchError`, failing the service start. For `better-sqlite3`, use the\n`BetterSqlite3Instrumentation` exposed by the\n`@atproto-labs/opentelemetry-node/better-sqlite3-plugin` entrypoint: a port of\n[`opentelemetry-plugin-better-sqlite3`](https://github.com/sjinks/opentelemetry-plugin-better-sqlite3)\nthat satisfies this requirement.\n\nThe `@atproto-labs/opentelemetry-node/conventions` entrypoint re-exports\n`@opentelemetry/semantic-conventions` plus the atproto-specific constants: the\nXRPC attribute keys (`ATTR_XRPC_METHOD`, `ATTR_XRPC_PROXIED`,\n`ATTR_XRPC_PROXY`), the bsync stash attribute keys (`ATTR_BSYNC_NAMESPACE`,\n`ATTR_BSYNC_OPERATION`), and `RPC_CALL_DURATION_BUCKETS`. The\n`@atproto-labs/opentelemetry-node/util` entrypoint exposes\n`statusCodeToString()`, which renders a Connect status as the snake_case string\n`rpc.response.status_code` expects.\n\nBoth sides of an RPC need to agree on these, so they live here rather than in\neither service — otherwise the caller and the callee end up depending on each\nother just to label a metric.\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\nBluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).\n","packages/internal/rolldown-plugin-bundle-manifest/README.md":"# @atproto-labs/rolldown-plugin-bundle-manifest\n\nThis Rolldown plugin allows to generate a (JSON) manifest containing the output\nfiles of a Rolldown build. The manifest will look as follows:\n\n```json\n{\n  \"main.js\": {\n    \"type\": \"chunk\",\n    \"mime\": \"application/javascript\",\n    \"dynamicImports\": [],\n    \"isDynamicEntry\": false,\n    \"isEntry\": true,\n    \"name\": \"main\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  },\n  \"main.js.map\": {\n    \"type\": \"asset\",\n    \"mime\": \"application/json\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  },\n  \"main.css\": {\n    \"type\": \"asset\",\n    \"mime\": \"text/css\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  }\n  // ... more entries as needed\n}\n```\n\nThis manifest will typically be useful for a backend service that serves the\nfrontend assets, as it can be used to determine the correct `Content-Type` and\nand file integrity (via the SHA-256 hash), without having to read the files\nthemselves.\n\n## Usage\n\n```js\n// rolldown.config.js\n\nimport { bundleManifest } from '@atproto-labs/rolldown-plugin-bundle-manifest'\n\nexport default {\n  input: 'src/index.js',\n  output: {\n    dir: 'dist',\n    format: 'es',\n  },\n  plugins: [\n    bundleManifest({\n      name: 'bundle-manifest.json',\n\n      // Optional: should the asset data be embedded (as base64 string) in the manifest?\n      data: false,\n    }),\n  ],\n}\n```\n\n## Options\n\n- `name` (string): The name of the manifest file. Defaults to `bundle-manifest.json`.\n- `data` (boolean): Whether to embed the asset data in the manifest. Defaults to `false`.\n\n## Example\n\n```js\nconst assetManifest = require('./dist/bundle-manifest.json')\n\nconst app = express()\n\napp.use((req, res, next) => {\n  const asset = assetManifest[req.path.slice(1)]\n  if (!asset) return next()\n\n  res.setHeader('Content-Type', asset.mime)\n  res.setHeader('Content-Length', asset.data.length)\n\n  res.end(Buffer.from(asset.data, 'base64'))\n})\n\napp.use((req, res, next) => {\n  res.setHeader(\n    'Content-Security-Policy',\n    buildCSP(assetManifest), // Not provided here\n  )\n\n  // Serve the index.html file\n  res.sendFile('index.html')\n})\n```\n\n## License\n\nMIT\n","packages/lex-cli/README.md":"# Lexicon CLI Tool\n\nCommand-line tool to generate Lexicon schemas and APIs.\n\n## Usage\n\n```\nUsage: lex [options] [command]\n\nLexicon CLI\n\nOptions:\n  -V, --version                     output the version number\n  -h, --help                        display help for command\n\nCommands:\n  new [options] <nsid> [outfile]    Create a new schema json file\n  gen-md <schemas...>               Generate markdown documentation\n  gen-ts-obj <schemas...>           Generate a TS file that exports an array of schemas\n  gen-api <outdir> <schemas...>     Generate a TS client API\n  gen-server <outdir> <schemas...>  Generate a TS server API\n  help [command]                    display help for command\n```\n\n**Example 1:** Generate an api\n\n```\n$ lex gen-api ./api/src ./schemas/com/service/*.json ./schemas/com/another/*.json\n```\n\n**Example 2:** Generate a server\n\n```\n$ lex gen-server ./server/src/xrpc ./schemas/com/service/*.json ./schemas/com/another/*.json\n```\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/lex/lex-builder/README.md":"# @atproto/lex-builder\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nGenerates TypeScript schemas from [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex build` command.\n\n```typescript\nimport { build } from '@atproto/lex-builder'\n\nawait build({ lexicons: './lexicons', out: './src/lexicons' })\n```\n\nMost projects should use the `lex build` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.\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/lex/lex-cbor/README.md":"# @atproto/lex-cbor\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nCBOR ([DRISL](https://dasl.ing/drisl.html)) encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), as used for [repository](https://atproto.com/specs/repository) storage and signing.\n\n```typescript\nimport { decode, encode, cidForLex } from '@atproto/lex-cbor'\n\nconst bytes = encode(someLexValue)\nconst value = decode(bytes)\nconst cid = await cidForLex(someLexValue)\n```\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family. Unlike the JSON codec, this package is _not_ re-exported by `@atproto/lex` and must be installed directly.\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/lex/lex-client/README.md":"# @atproto/lex-client\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n- [`@atproto/lex-rpc` low-level XRPC utilities](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex-rpc/README.md)\n\nType-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) client for Lexicon-defined APIs: the `Client` class for working with a user's PDS (records) in the context of an authenticated session.\n\nAuthentication is supplied through an `Agent`, implemented by [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) or, for scripts and bots, [`@atproto/lex-password-session`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-password-session).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it -- prefer depending on that package.\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/lex/lex-data/README.md":"# @atproto/lex-data\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nCore types and utilities for the AT Protocol [data model](https://atproto.com/specs/data-model): `LexValue` and friends, CIDs, bytes, [blob](https://atproto.com/specs/blob) references, and string-length helpers.\n\nThe data model extends JSON with two additional structures — CIDs (content-addressed links) and raw bytes — which are encoded as JSON by [`@atproto/lex-json`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-json) and as CBOR by [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.\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/lex/lex-document/README.md":"# @atproto/lex-document\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nValidation of Lexicon documents themselves — the JSON files written in the [Lexicon schema definition language](https://atproto.com/specs/lexicon).\n\nWhere [`@atproto/lex-schema`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-schema) validates _data_ against a schema, this package validates the _schema documents_, and turns them into runtime schemas by resolving refs across documents.\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`lex-builder`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-builder), [`lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) and [`lex-resolver`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-resolver).\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/lex/lex-installer/README.md":"# @atproto/lex-installer\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nPackage manager for [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex install` command: it resolves Lexicons by [NSID](https://atproto.com/specs/nsid), writes them to a local directory, and tracks their versions (CIDs) in a `lexicons.json` manifest.\n\n```typescript\nimport { install } from '@atproto/lex-installer'\n\nawait install({\n  lexicons: './lexicons',\n  manifest: './lexicons.json',\n  add: ['app.bsky.feed.post'],\n  save: true,\n})\n```\n\nMost projects should use the `lex install` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.\n\n## Resolution sources\n\nEach Lexicon is resolved from, in order of priority:\n\n1. **An explicit `at://` URI** passed as an addition — fetched (and verified) from that repo.\n2. **An explicit local file** passed as an addition — any value that is a `file://` URI or\n   contains a slash (`./app.bsky.feed.post.json`, `file://../shared/my.json`) is read from disk\n   (relative to the cwd); its NSID is taken from the document's `id`.\n3. **The manifest's `resolvers`** — an ordered list of local override strategies consulted for\n   every dependency NSID before the network. Currently only `directory` is supported:\n\n   ```jsonc\n   {\n     \"version\": 1,\n     \"lexicons\": [\"com.example.foo\"],\n     \"resolvers\": [\n       {\n         \"type\": \"directory\",\n         \"path\": \"../../lexicons\", // relative to this lexicons.json\n         \"include\": [\"com.example.*\"], // optional NSID globs (default: all)\n         \"exclude\": [], // optional NSID globs this resolver never answers for\n       },\n     ],\n     \"resolutions\": {\n       \"com.example.foo\": {\n         \"uri\": \"file://../../lexicons/com/example/foo.json\",\n         \"cid\": \"...\",\n       },\n     },\n   }\n   ```\n\n   Resolvers are priority-ordered (first match wins) and are **not** reordered by manifest\n   normalization. A `directory` resolver maps an NSID to `<path>/<a>/<b>/<c>.json`.\n\n4. **The network** — the always-last fallback: NSID → DNS `_lexicon.<authority>` → DID → signed\n   `com.atproto.lexicon.schema` record.\n\n> [!NOTE]\n> A future `{ \"type\": \"repo\", ... }` resolver (resolving from specific DIDs rather than DNS\n> discovery) is planned but not yet implemented.\n\n### Symlinks\n\nLexicons resolved from a **local file** (sources 2 and 3 above) are **symlinked** into the output\n`lexicons/` directory rather than copied, so edits to the source file are reflected without\nreinstalling. The symlink is written relative to its destination for portability. As an exception,\nif the resolved destination path equals the source path, the file is left untouched. Their manifest\nlock `uri` is a `file://` path relative to the manifest, keeping it portable across checkouts.\nNetwork-resolved Lexicons are written as plain JSON files and locked with their `at://` URI.\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/lex/lex-json/README.md":"# @atproto/lex-json\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nJSON encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), including the `$link` (CID) and `$bytes` representations used by [XRPC](https://atproto.com/specs/xrpc).\n\nProvides `lexParse` / `lexStringify` to go between JSON strings and data-model values, and `jsonToLex` / `lexToJson` to convert already-parsed JSON objects. For the CBOR codec used by repository storage, see [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.\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/lex/lex-password-session/README.md":"# @atproto/lex-password-session\n\nPassword-based session authentication for AT Protocol Lexicons. See the [Changelog](./CHANGELOG.md) for version history.\n\n```bash\nnpm install @atproto/lex-password-session\n```\n\n- Session management with automatic token refresh\n- Hooks for persisting and monitoring session state\n- PDS endpoint discovery from DID documents\n- Two-factor authentication support\n\n> [!IMPORTANT]\n>\n> This package is currently in **preview**. The API and features are subject to change before the stable release.\n\n**What is this?**\n\n`@atproto/lex-password-session` provides a `PasswordSession` class that implements the `Agent` interface from `@atproto/lex-client`. It handles password-based authentication with AT Protocol services, including:\n\n1. Creating sessions with username/password credentials\n2. Automatic token refresh when access tokens expire\n3. Session persistence through lifecycle hooks\n4. Graceful logout with server-side session cleanup\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport * as app from './lexicons/app.js'\n\n// Login with credentials\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\n\nconst client = new Client(session)\n\n// Make authenticated requests\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: session.did,\n})\n```\n\n<!-- START doctoc generated TOC please keep comment here to allow auto update -->\n<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->\n\n- [Quick Start](#quick-start)\n- [PasswordSession](#passwordsession)\n  - [Login](#login)\n  - [Two-Factor Authentication](#two-factor-authentication)\n  - [Resume Session](#resume-session)\n  - [Logout](#logout)\n  - [Static Delete](#static-delete)\n  - [Create Account](#create-account)\n- [Session Hooks](#session-hooks)\n  - [onUpdated](#onupdated)\n  - [onUpdateFailure](#onupdatefailure)\n  - [onDeleted](#ondeleted)\n  - [onDeleteFailure](#ondeletefailure)\n- [Session Data](#session-data)\n- [Error Handling](#error-handling)\n- [Using with Client](#using-with-client)\n- [License](#license)\n\n<!-- END doctoc generated TOC please keep comment here to allow auto update -->\n\n## Quick Start\n\n**1. Install the package**\n\n```bash\nnpm install @atproto/lex-password-session @atproto/lex-client\n```\n\n**2. Login and make requests**\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your-handle.bsky.social',\n  password: 'your-app-password',\n})\n\nconst client = new Client(session)\n\n// Make authenticated API calls\nconsole.log('Logged in as:', session.did)\n```\n\n## PasswordSession\n\nThe `PasswordSession` class manages password-based authentication sessions.\n\n### Login\n\nCreate a new session with username and password:\n\n```typescript\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social', // handle or email\n  password: 'app-password',\n  onUpdated: (data) => {\n    // Persist session for later restoration\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: () => {\n    localStorage.removeItem('session')\n  },\n})\n\nconsole.log('Logged in as:', session.did)\n```\n\nThe `login()` method throws on failure. For expected errors like invalid credentials, an `XrpcResponseError` is thrown. For 2FA requirements, a `LexAuthFactorError` is thrown.\n\n### Two-Factor Authentication\n\n> [!CAUTION]\n>\n> Two-factor authentication only applies when using **main account credentials**, which is **strongly discouraged**. Password authentication should be used with [app passwords](https://bsky.app/settings/app-passwords) only because they are designed for programmatic access (bots, scripts, CLI tools). For user-facing applications, use OAuth via [@atproto/oauth-client](../../../oauth/oauth-client) which provides better security and user control.\n\nIf the account has 2FA enabled, login will throw a `LexAuthFactorError`:\n\n```typescript\nimport {\n  PasswordSession,\n  LexAuthFactorError,\n} from '@atproto/lex-password-session'\n\nasync function loginWith2FA(\n  identifier: string,\n  password: string,\n  authFactorToken?: string,\n): Promise<PasswordSession> {\n  try {\n    return await PasswordSession.login({\n      service: 'https://bsky.social',\n      identifier,\n      password,\n      authFactorToken,\n      onUpdated: (data) => saveToStorage(data),\n      onDeleted: (data) => removeFromStorage(data.did),\n    })\n  } catch (err) {\n    if (err instanceof LexAuthFactorError && !authFactorToken) {\n      // 2FA required - prompt user for code\n      const token = await promptUserFor2FACode(err.message)\n      return loginWith2FA(identifier, password, token)\n    }\n    throw err\n  }\n}\n```\n\n### Resume Session\n\nRestore a previously saved session:\n\n```typescript\nimport { PasswordSession, SessionData } from '@atproto/lex-password-session'\n\n// Load session from storage\nconst savedSession: SessionData = JSON.parse(localStorage.getItem('session')!)\n\n// Resume the session (automatically refreshes tokens)\nconst session = await PasswordSession.resume(savedSession, {\n  onUpdated: (data) => {\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: () => {\n    localStorage.removeItem('session')\n  },\n})\n\nconsole.log('Session resumed for:', session.did)\n\n// Access session properties\nconsole.log(session.did) // User's DID\nconsole.log(session.handle) // User's handle\nconsole.log(session.destroyed) // false (session is active)\n```\n\n> [!NOTE]\n>\n> `resume()` automatically calls `refresh()` to ensure the session is valid and tokens are current.\n\n### Logout\n\nEnd the session and notify the server:\n\n```typescript\nawait session.logout()\n```\n\nAfter logout:\n\n- The `onDeleted` hook is called\n- The session is marked as destroyed (`session.destroyed === true`)\n- Further requests will throw `'Logged out'`\n\n### Static Delete\n\nDelete a session without creating a session instance:\n\n```typescript\nimport { PasswordSession, SessionData } from '@atproto/lex-password-session'\n\nconst data: SessionData = JSON.parse(localStorage.getItem('session')!)\n\n// Delete the session on the server\nawait PasswordSession.delete(data)\n```\n\nThis is useful for cleanup scenarios where you don't need to make additional requests.\n\n### Create Account\n\nCreate a new account and get an authenticated session:\n\n```typescript\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.createAccount(\n  {\n    handle: 'alice.bsky.social',\n    email: 'alice@example.com',\n    password: 'secure-password',\n  },\n  {\n    service: 'https://bsky.social',\n    onUpdated: (data) => saveToStorage(data),\n    onDeleted: (data) => removeFromStorage(data.did),\n  },\n)\n\nconsole.log('Account created:', session.did)\n```\n\n## Session Hooks\n\nHooks provide callbacks for session lifecycle events. All hooks receive the session instance as `this` context.\n\n### onUpdated\n\nCalled when the session is successfully created or refreshed:\n\n```typescript\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n  onUpdated(data) {\n    // `this` is the PasswordSession instance\n    console.log('Session updated for:', this.did)\n\n    // Persist the updated session\n    saveSession(data)\n  },\n})\n```\n\n> [!IMPORTANT]\n>\n> Requests are blocked while `onUpdated` is running. Keep this callback fast to avoid delays.\n\n### onUpdateFailure\n\nCalled when token refresh fails due to transient errors (network issues, server unavailability):\n\n```typescript\n{\n  onUpdateFailure(data, error) {\n    console.warn('Token refresh failed:', error.message)\n    // Session may still be valid - consider retry logic\n  }\n}\n```\n\n### onDeleted\n\nCalled when the session is terminated (logout or server-side invalidation):\n\n```typescript\n{\n  onDeleted(data) {\n    console.log('Session ended for:', data.did)\n    clearPersistedSession(data.did)\n    redirectToLogin()\n  }\n}\n```\n\n### onDeleteFailure\n\nCalled when logout fails due to transient errors:\n\n```typescript\n{\n  onDeleteFailure(data, error) {\n    console.error('Logout failed:', error.message)\n    // Consider queuing for retry to avoid orphaned sessions\n    queueLogoutRetry(data)\n  }\n}\n```\n\n> [!WARNING]\n>\n> Ignoring delete failures can leave sessions active on the server. Implement retry logic for security-sensitive applications.\n\n## Session Data\n\nThe `SessionData` type contains all data needed to authenticate and restore sessions:\n\n```typescript\ntype SessionData = {\n  // Session credentials and user info from createSession response\n  accessJwt: string\n  refreshJwt: string\n  did: string\n  handle: string\n  email?: string\n  emailConfirmed?: boolean\n  didDoc?: object\n  // ... other fields from createSession\n\n  // Original service URL used for login\n  service: string\n}\n```\n\n## Error Handling\n\nThe `PasswordSession` class uses exception-based error handling:\n\n```typescript\nimport {\n  PasswordSession,\n  LexAuthFactorError,\n} from '@atproto/lex-password-session'\nimport { XrpcResponseError } from '@atproto/lex-client'\n\ntry {\n  const session = await PasswordSession.login({\n    service: 'https://bsky.social',\n    identifier: 'alice.bsky.social',\n    password: 'wrong-password',\n  })\n} catch (err) {\n  if (err instanceof LexAuthFactorError) {\n    console.error('2FA required')\n  } else if (err instanceof XrpcResponseError) {\n    switch (err.error) {\n      case 'AuthenticationRequired':\n        console.error('Invalid credentials')\n        break\n      case 'AccountTakedown':\n        console.error('Account has been suspended')\n        break\n      default:\n        console.error('Login failed:', err.message)\n    }\n  } else {\n    throw err\n  }\n}\n```\n\nCommon error codes:\n\n| Error Code                | Description                    |\n| ------------------------- | ------------------------------ |\n| `AuthenticationRequired`  | Invalid username or password   |\n| `AuthFactorTokenRequired` | 2FA code needed                |\n| `AccountTakedown`         | Account suspended              |\n| `ExpiredToken`            | Token has expired (on refresh) |\n| `InvalidToken`            | Token is invalid               |\n\n## Using with Client\n\nThe `PasswordSession` implements the `Agent` interface and can be used directly with `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport * as app from './lexicons/app.js'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n})\n\nconst client = new Client(session)\n\n// The client automatically uses the session for authentication\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: client.assertDid,\n})\n\n// Tokens are automatically refreshed when expired\nconst timeline = await client.call(app.bsky.feed.getTimeline, {\n  limit: 50,\n})\n\n// Create records\nawait client.create(app.bsky.feed.post, {\n  text: 'Hello from lex-password-session!',\n  createdAt: new Date().toISOString(),\n})\n```\n\nThe session handles:\n\n- Adding `Authorization` headers to requests\n- Detecting expired tokens (401 responses or `ExpiredToken` errors)\n- Automatically refreshing tokens and retrying failed requests\n- Routing requests to the correct PDS based on DID document\n\n## License\n\nMIT or Apache2\n","packages/lex/lex-resolver/AGENTS.md":"# AGENTS.md\n\n## Lexicon codegen: exception to the per-package `lexicons.json` convention\n\nThis package does **not** follow the repo-wide lexicon-management convention (a\nper-package `lexicons.json` manifest resolved with `lex install`; see\n[CONTRIBUTING → \"Working with lexicons\"](../../../CONTRIBUTING.md#working-with-lexicons)\nand the `lex-setup` skill). It has no `lexicons.json` and compiles the canonical\nschemas straight from the repo-root [lexicons/](../../../lexicons/):\n\n```\n\"codegen:lex\": \"lex-builder build --lexicons ../../../lexicons --out ./src/lexicons --clear --lib @atproto/lex-schema --include com.atproto.sync.getRecord --include com.atproto.lexicon.schema\"\n```\n\n**Why:** the installer (`@atproto/lex-installer`) depends on `@atproto/lex-resolver`,\nso this package cannot depend on the installer to manage a manifest without\ncreating a dependency cycle. It only needs canonical (`com.atproto.*`) schemas,\nwhich live in the root `lexicons` directory anyway, so compiling them directly is\nsufficient.\n\n**Do not** add a `lexicons.json`, `@atproto/lex`, or `@atproto/lex-installer`\nhere, and do not \"migrate\" this `codegen:lex` to the `lex install` form — keep the\n`lex-builder build --lexicons ../../../lexicons --include …` shape. Only\n`/src/lexicons` is gitignored (generated); there is no resolved `./lexicons`\ndirectory to manage.\n","packages/lex/lex-resolver/README.md":"# @atproto/lex-resolver\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nResolves [Lexicon](https://atproto.com/specs/lexicon) documents from the AT Protocol network by [NSID](https://atproto.com/specs/nsid).\n\n```typescript\nimport { LexResolver } from '@atproto/lex-resolver'\n\nconst resolver = new LexResolver({})\nconst { lexicon, uri, cid } = await resolver.get('app.bsky.feed.post')\n```\n\nResolution looks up the `_lexicon.<authority>` DNS TXT record to find the controlling [DID](https://atproto.com/specs/did), resolves that DID to a PDS, then fetches and cryptographically verifies the Lexicon record. Hooks allow caching at the fetch layer.\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`@atproto/lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) to implement `lex install`.\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/lex/lex-rpc/README.md":"# @atproto/lex-rpc\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nType-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) utilities for Lexicon-defined APIs: exposes low-level `xrpc()` / `xrpcSafe()` functions for making requests based on Lexicon derived schemas.\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/lex/lex-schema/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex-schema%2FREADME.md -->","packages/lex/lex-server/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex-server%2FREADME.md -->","packages/lex/lex/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex%2FREADME.md -->","packages/lexicon-resolver/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flexicon-resolver%2FREADME.md -->","packages/lexicon/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flexicon%2FREADME.md -->","packages/oauth/oauth-client-browser/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-browser%2FREADME.md -->","packages/oauth/oauth-client-browser/src/indexed-db/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-browser%2Fsrc%2Findexed-db%2FREADME.md -->","packages/oauth/oauth-client-expo/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-expo%2FREADME.md -->","packages/oauth/oauth-client-node/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-node%2FREADME.md -->","packages/oauth/oauth-client/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client%2FREADME.md -->","packages/oauth/oauth-provider-ui/CLAUDE.md":"# oauth-provider-ui\n\nUI for the OAuth provider's authorization & account-management screens. React 19, TanStack Router, Tailwind 4, shadcn/ui on Base UI, native-constraint forms on `@base-ui/react/form`, Lingui i18n.\n\n## Imports\n\n**Import from `react` by name — `import { useState, type ComponentProps }`.**\nNever `import * as React`, and never reach for the `React.*` global without an\nimport at all (`@types/react` declares it, so `React.ComponentProps` compiles\nwith no import and silently introduces a third style).\n\nThe exception is `components/ui/*`, which is shadcn CLI output and stays\nbyte-faithful — the registry emits `import * as React`, and rewriting it would\nput a diff in every file on the next `shadcn add`. The boundary is the\ndirectory: inside it, whatever the registry emits; everywhere else, named\nimports.\n\n## Verification\n\nType-check from the package directory:\n\n```bash\ncd packages/oauth/oauth-provider-ui\npnpm exec tsc --build tsconfig.json\npnpm test\npnpm run build:ui\n```\n\n**Run eslint from the repo root, not the package.**\n\n```bash\ncd /path/to/atproto\npnpm exec eslint --fix 'packages/oauth/oauth-provider-ui/src/**/*.tsx'\n```\n\nPrettier's Tailwind plugin reorders class strings. Accept its order.\n\n## Looking at the UI\n\n`pnpm dev:ui` serves all four entry pages on port 5174 against `src/mock-api.ts`\n— no PDS, no docker, no dev-env. Prefer it over booting the dev environment.\n\nThe account page starts signed out. The account rows in the picker are not\n`<button>`s — click `[aria-label^=\"Sign in as\"]`. The router uses real paths, not\nhashes, so navigate by clicking sidebar links rather than setting\n`location.hash`.\n\nEvery path under `/account` serves the account page, as it does behind the PDS\n(the `mock-account-paths` plugin in `vite.config.js`), so deep links and\nrefreshes work. That is what makes the mock usable for anything to do with URL\nstate — `account-page.html` must therefore keep its imports absolute and leave\nthe path alone when it is already under `/account/`.\n\n## Routing\n\n**File-based.** `src/routes/` is the route tree, and `src/routeTree.gen.ts` is\ngenerated from it by `@tanstack/router-plugin` on every dev run and build. The\ngenerated file is committed; don't edit it, and don't add it to a lint or format\npass (it is already in `.prettierignore`). `autoCodeSplitting` gives every route\ncomponent its own chunk — no `lazyRouteComponent` by hand.\n\nNaming follows TanStack's conventions: `__root.tsx`, `index.tsx` for a path's\nown page, a `$` prefix for params, `route.tsx` for a directory's layout, and a\n`-` prefix for files that are colocated content rather than routes (the About\npage's localized copies live in `-about/`).\n\n**Guards go in `beforeLoad`, never in render.** `/account` decides where the\ndevice's sessions belong (welcome / picker / the selected account) and\n`/account/u/$accountId` resolves the account the URL names, each throwing\n`redirect()`. `beforeLoad` returns `{ session }`, so descendants read the\naccount from `Route.useRouteContext()` instead of resolving it again. Rendering\na `<Navigate>` to gate a page reintroduces a render-then-bounce and a window\nwhere children fetch as the wrong account.\n\n**Router context** carries what the guards and loaders need but can't reach\nthrough React: `auth` (the session store), `api`, and `queryClient`. It is\nsupplied at `<RouterProvider context={…}/>` in `account-page.tsx`.\n\nTwo consequences that are easy to get wrong:\n\n- `auth` is `SessionContextType.store`, whose fields are **getters**, not a\n  render snapshot. A guard runs during a navigation that is usually dispatched\n  from the same event handler that just mutated the session, so a captured\n  value would be one render stale and the guard would reject the account it was\n  just sent to.\n- Changing the context does not re-run the guards. `account-page.tsx`\n  invalidates the router when the sessions change, which is what makes signing\n  out move the user with no navigation wired into the action.\n\nData pages load through the route: `loader` calls\n`queryClient.ensureQueryData(…QueryOptions(api, …))` and the component reads the\nsame query with `useSuspenseQuery`, so there is no in-component loading state.\nFailures surface through the route's `errorComponent` (whose `reset` retries).\n\nThe third-party consent flow has no router at all — it is a linear wizard and\nkeeps `AuthenticationProvider`.\n\nA link's `to` is a path _template_ (`/account/u/$accountId/manage`) — it never\nequals `location.pathname`. Test the current page with `useMatchRoute`\n(`useIsCurrentTarget` in `AccountShell`) and mark active links with\n`activeOptions={{ exact: true }}`; without `exact`, the account home matches\nevery page below it.\n\n## Components\n\n- `components/ui/*` — shadcn primitives, style `base-nova`, on `@base-ui/react`.\n  **Generated by the shadcn CLI, not hand-written.** Regenerate into a scratch\n  project and copy across, applying only these rewrites: `@/lib/utils` →\n  `#/lib/utils.ts`, `@/registry/…/ui/x` → `#/components/ui/x.tsx`, and drop any\n  `'use client'` directive (a Next.js RSC marker; this app is Vite). Keep these\n  files byte-faithful to upstream so future `shadcn add` stays mergeable — put\n  deviations in wrapper components instead. Base UI composes via\n  `render={<El/>}`, not a `asChild`-style prop.\n  The registry's components assume `@custom-variant` bridges that ship in the\n  style's `globals.css`, so copying `components/ui/*` alone is not enough — see\n  the orientation bridge in `src/style.css`. `Separator` silently rendered at\n  zero width/height until it was added.\n  Every file in there is a real registry entry — if `shadcn add <name>` under\n  this style returns an item with no files, the component is ours and belongs\n  outside this directory.\n- `components/forms/*` — `FormShell` (the form frame: action row, submit/cancel/\n  back, root error), `fields/*` (one wrapper per input type), plus\n  `AsyncButton`, `CopyButton`, `RequestCodeButton`, `SignUpWizard`.\n- `components/dialogs/*` — `DialogShell` (frame).\n- `components/feedback/*` — `Notice`, `ErrorNotice`, `ErrorCard`,\n  `ErrorDetails`, `PasswordStrength`.\n- `components/identity/*` — account/client avatars, names, identifiers, the\n  account menu.\n- `components/layouts/*` — `AuthShell` (the centred auth card) and\n  `AccountShell` (sidebar + content). These two own the whole page frame,\n  including the `<title>`, so they must never nest.\n- Pages under `src/pages/` and the four `*-page.tsx` entry points own contexts,\n  TanStack Query and routing.\n\n## Forms\n\nForms are native: `FormShell` (on `@base-ui/react/form`) plus one field wrapper\nper input. Validation is HTML constraint attributes on the controls —\n`required`, `type`, `pattern`, `minLength` — so an invalid submit is blocked by\nthe browser with its own bubble, in the browser's locale. There is no form\nlibrary, no schema layer, and no store: the DOM is the source of truth, and\n`FormShell` reads the values off the element with `FormData`.\n\n```tsx\n<FormShell<Values> onSubmit={(values, signal) => handler(values, signal)}>\n  <EmailField name=\"email\" required label={<Trans>Email</Trans>} />\n</FormShell>\n```\n\nShared regexes for `pattern` attributes live in `src/lib/form-patterns.ts`.\nNote `pattern` sources are implicitly anchored — no `^`/`$` — and compiled with\nthe `v` flag, under which a literal `-` in a character class must be escaped\n(`[a-z0-9\\-]`). A pattern that fails to compile is silently ignored by the\nbrowser (Chrome logs \"Pattern attribute value … is not a valid regular\nexpression\"), disabling the constraint. The pds `oauth.test.ts` asserts\n`validity.patternMismatch` on the sign-in identifier to catch this in a real\nbrowser.\n\nBase UI rules this layer depends on — each one was a real, browser-only bug:\n\n- **Values must come from `FormData`, never from `onFormSubmit`'s argument.**\n  Base UI only passes values for controls registered through `Field.Root`; a\n  plain named input (a checkbox, a hidden input) is silently absent. `FormShell`\n  already does this — don't \"simplify\" it back.\n- **`Form` defaults to `noValidate`.** `FormShell` re-enables the browser's\n  gate; without it, any control outside a `Field.Root` is validated by no one.\n- **A `Field.Root` binds every descendant control to its own name**, even over\n  an explicit `name` prop. A field composed of two controls (`NewHandleField`)\n  must opt out of `Field` entirely.\n- **A `disabled` control is omitted from form values.** Use `readOnly` when the\n  value must still submit.\n- **`type=\"email\"` accepts `user@host` with no dot** — `EmailField` carries a\n  `pattern` requiring a TLD on top.\n\n`FormShell` submits through `useAsyncAction`, which handles abort-on-unmount,\nabort-on-supersede and error retention. Errors render via `ErrorNotice` +\n`apiErrorParser`, so typed OAuth error payloads keep their user-facing messages.\nFor callers that must survive remounting (the sign-up wizard's back/forward),\n`onValues` mirrors the form's values on every edit.\n\n**Reserve space for anything that appears conditionally** next to an\ninteractive control — a layout shift between mousedown and mouseup lands the\nclick on a different element and silently swallows it.\n\n### Field names are a public contract\n\nThe `name` attribute on each control is selected on by the pds e2e suite and by\npassword managers. These names are owned by this package and must not be\nrenamed: `identifier`, `password`, `remember`, `token`, `emailOtp`, `email`,\n`handle`, `domain`, `inviteCode`.\n\nControls are named after the API field they feed (`identifier`, `token`,\n`emailOtp`), so values pass through without remapping. Keep it that way when\nadding one. Identifier fields also keep `autoComplete=\"username\"`, which is\nwhat marks them for password managers.\n\n## i18n (Lingui)\n\n- `.po` files under `src/locales/*` are generated; don't hand-edit except to add\n  French translations.\n- After any `.tsx` edit run `pnpm i18n`, then **check the diff for added or\n  removed `msgid`s**. Only line-reference churn is expected. This check has\n  caught two bugs that nothing else would have.\n- msgids are the English source strings, so changing a string orphans five\n  locales. `<Trans>` placeholders are positional (`<0>`, `<1>`) and derive from\n  JSX nesting — adding or reordering an element inside a `<Trans>` renumbers them\n  and changes the msgid. Recompose _around_ `<Trans>` blocks. Expression\n  placeholders are named by the identifier — `{minLength}` and `{MIN_LENGTH}`\n  are different msgids — so alias a renamed variable back rather than touching\n  the string.\n- **Never pass `t` as a prop.** The macro only transforms `` t`…` `` in a scope\n  that imports `useLingui` from `@lingui/react/macro`; passing `t` down leaves\n  the template uncompiled and the string untranslated, and it silently vanishes\n  from the catalogs.\n- A msgid that disappears and returns loses its translation. After extraction,\n  check the _count_ of untranslated entries, not just the msgid diff.\n- Fill in French only; other locales are translated externally.\n\n## The pds e2e contract\n\n`packages/pds/tests/{oauth,account-manager}.test.ts` drive this UI by text and\ntag, so markup choices are load-bearing. The test files themselves are the\nauthoritative record of which text sits in which element. The rules:\n\n- actions the tests click must be real `<button>`s\n- body copy in `<p>`, page/section headings in the tag the test names\n  (`h2`/`h3`), identifiers and radio labels in `<span>`, code samples in `<code>`\n- dialog content keeps `role=\"dialog\"` and a `button[type=\"submit\"]`\n- `CardDescription` and `AlertDescription` render `<div>`, so wrap asserted copy\n  in an explicit `<p>`. **`DialogDescription` does not** — Base UI's\n  `Dialog.Description` is already a `<p>`, and wrapping it produced `<p><p>`,\n  which React reports as invalid nesting. Check the primitive's rendered element\n  before adding a wrapper rather than assuming they match.\n\nDialogs must cap their height (`DialogShell` does): Base UI's `DialogContent` is\n`fixed` with no height limit, and a tall dialog on a short viewport puts its\nbuttons out of reach with no way to scroll to them.\n\n## Branding\n\n`CustomizationData` supplies the logo, name, footer links and\n`availableUserDomains`. Colours arrive separately, at runtime: the provider\ninjects `--branding-color-*` variables (see\n`oauth-provider/src/customization/build-customization-css.ts`), and the\n\"Runtime branding\" section of `src/style.css` maps `primary`, `error`, `info`,\n`warning` and `success` onto the shadcn tokens. Each is one injected RGB that\nconsumers tint with opacity modifiers, not the old multi-shade hue palette.\nWhen a deployment configures nothing the tokens fall back to the pre-redesign\nstock palette (a purple primary) — deliberately not Bluesky's, so an\nuncurated PDS is not mistaken for one.\n\n`branding.background` customises the auth screen's page area (behind the card,\nnot the card itself). It carries a light and a dark image URL; the provider\ninjects `--branding-background-{light,dark}-image` (wrapped in `url(...)`), and\nthe `.auth-background` rule in `src/style.css` paints that image over the neutral\n`muted` base, picking the mode by `prefers-color-scheme`. Unset, it falls back to\nthe plain `muted` surface it always was. The card keeps its own opaque surface so\ncopy stays legible over any background.\n\nThe mock pages inject the same variables a branded deployment would, so\n`pnpm dev:ui` demonstrates branding without a PDS.\n\n## Utility types\n\n`Override<A, B>` (from `#/lib/util.ts`) is the standard prop-extension pattern:\n\n```tsx\ntype Props = Override<JSX.IntrinsicElements['button'], { icon: LucideIcon }>\n```\n\nIcons are `lucide-react`.\n\n## Linting quirks\n\nESLint requires unused vars/args to match `/^_/u`. Drop the destructure rather\nthan prefixing when the prop is genuinely unused.\n","packages/oauth/oauth-provider-ui/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider-ui%2FREADME.md -->","packages/oauth/oauth-provider/src/lib/html/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider%2Fsrc%2Flib%2Fhtml%2FREADME.md -->","packages/oauth/oauth-provider/src/lib/http/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider%2Fsrc%2Flib%2Fhttp%2FREADME.md -->","packages/oauth/oauth-types/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-types%2FREADME.md -->","packages/ozone/AGENTS.md":"# Ozone moderation inbox\n\n- Reject wildcard `queryLabels` requests before accessing the DB unless `OZONE_LABEL_QUERY_WILDCARDS_ENABLED` is enabled; exact queries remain available.\n- Execute cancellable listing SQL with the HTTP signal and Kysely's `inflightQueryAbortStrategy: 'cancel query'`; a signal alone leaves PostgreSQL work running.\n- Reporter statistics seek recent non-appeal reports by reporter/time and look up distinct linked action IDs by primary key; never join reports to subject event history.\n\n- Read previews share `inboxViewerDid`; writes always use the authenticated DID.\n- Notification preference writes atomically patch only provided categories. Unset category columns inherit the retained push column to preserve existing opt-outs without a backfill; preferences do not suppress in-app inbox activity.\n- Produce moderation notifications from the common `ModerationService.logEvent` path so daemon actions are covered. Keep source snapshots in the source transaction, and isolate optional queries/inserts with `runNotificationWork` savepoints; a caught SQL error alone leaves PostgreSQL's transaction aborted.\n- Batch report notifications and skip non-transition activities before querying. Viewer responses never expose moderator note text.\n- Inbox appeal state and filters prioritize active reports; eligibility considers inbox appeal reports, including reopened and pre-cutoff history. Serialize inbox appeal submissions by subject; subject reviewState and appealed flags are not synchronized by report activities. Unread includes appeal report updates, independently of the requested sort field. Apply filters before pagination.\n- Appeal outcomes come from the current close activity and its linked corrective event, never merely nonempty actionEventIds (which also contain the appealed action). No-action appeal closures record denied in activity metadata; unknown historical closures omit the outcome. Resolution notifications snapshot the outcome.\n- Seek the latest appeal per requested subject with an indexed lateral LIMIT before joining its closure and resolution event; outcome work must be bounded by the page size, not appeal history.\n- Read `NEXT_PUBLIC_STRIKE_SUSPENSION_CONFIG` through configuration. The second and third sorted strike thresholds determine warning and atRisk; retain the full hours map, including Infinity.\n- Use explicit predicates for partial indexes on heavy tables. Report pagination seeks report.reporterDid/time indexes, validates source ownership before LIMIT by lateral primary-key lookup, and materializes only the page before hydration. Reporter backfill/catch-up is a deployment prerequisite.\n- OZONE_INBOX_START_AT bounds history by inclusive creation time before pagination/aggregation, including details and notification targets. Express the cutoff on the sort/seek index's own column (report updatedAt pages also bound `updatedAt`), join per-DID aggregates to statuses on indexed columns (`recordPath`), and bind the viewer DID as a constant in correlated `EXISTS` checks: PostgreSQL may hash such subplans and drop correlation. Keep live standing/enforcement and historical appeal eligibility authoritative; only display post-cutoff appeal history.\n- Migrations that use report.reporterDid must run after _20261002T000000000Z. Pre-create large indexes concurrently in production and verify they are valid and ready before running transactional migrations.\n","packages/ozone/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fozone%2FREADME.md -->","packages/pds/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fpds%2FREADME.md -->","packages/pds/src/scripts/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fpds%2Fsrc%2Fscripts%2FREADME.md -->","packages/repo/AGENTS.md":"# packages/repo\n\n## MST traversals MUST call `markVisited`\n\nEvery code path that can visit more than one child of an MST node — full-tree\nwalks, diffs, block collection; recursive or iterative; in `src/mst/mst.ts`,\n`walker.ts`, `diff.ts`, or new code anywhere — **MUST**:\n\n1. create a fresh `CidSet` per traversal (never share one between traversals),\n   and\n2. call `node.markVisited(seen)` on every `MST` node it enters, before reading\n   that node's entries (`getEntries`, `atIndex`, …).\n\nThis is a requirement, not an optimization: a traversal that skips it is a bug\neven if every test passes. When adding a traversal, add a matching case to\n`tests/mst-dag.test.ts`.\n\nExempt: lookups that follow a single path (one recursive call per level, e.g.\n`cidsForPath`, `proofForKey`), which are bounded by tree depth, and walks over\nin-memory nodes built by local mutations (outdated pointers, e.g. `serialize`),\nsince nodes loaded from storage never have one.\n\n`MST.markVisited` throws `VisitedCidError` on a repeated node and skips nodes\nwith an outdated pointer (not yet serialized).\n","packages/repo/CLAUDE.md":"# CLAUDE.md\n\n@AGENTS.md\n","packages/repo/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Frepo%2FREADME.md -->","packages/sync/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fsync%2FREADME.md -->","packages/syntax/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fsyntax%2FREADME.md -->","packages/tap/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Ftap%2FREADME.md -->","packages/ws-client/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fws-client%2FREADME.md -->","packages/xrpc-server/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fxrpc-server%2FREADME.md -->","packages/xrpc/README.md":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fxrpc%2FREADME.md -->"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\n## Repository overview\n\nThis is the TypeScript reference implementation of [AT Protocol](https://atproto.com), the decentralized social media protocol behind Bluesky. It is a pnpm monorepo (runtime floor Node.js ≥22) containing client libraries, schema/codegen tooling, and the two main service implementations: the Personal Data Server (PDS) and the `app.bsky` AppView.\n\nWorkspace layout (see [pnpm-workspace.yaml](./pnpm-workspace.yaml) and [tsconfig.json](./tsconfig.json)):\n\n- [packages/\\*](packages/) — top-level libraries: `api`, `common`, `crypto`, `identity`, `lexicon`, `repo`, `syntax`, `xrpc`, `xrpc-server`, `pds`, `bsky`, `bsync`, `ozone`, `dev-env`, `dev-infra`, etc.\n- [packages/lex/\\*](packages/lex/) — the modern type-safe Lexicon SDK family (`@atproto/lex`, `lex-builder`, `lex-cbor`, `lex-client`, `lex-data`, `lex-document`, `lex-json`, `lex-resolver`, `lex-server`, `lex-schema`, `lex-installer`, `lex-password-session`). New service code should use this in preference to the older `@atproto/api` / `@atproto/lexicon` / `@atproto/xrpc` / `@atproto/lex-cli` stack — see the `lex-*` skills listed under [Codegen](#codegen).\n- [packages/oauth/\\*](packages/oauth/) — OAuth client/provider implementations and JWK helpers.\n- [packages/internal/\\*](packages/internal/) — `@atproto-labs/*` internal shared utilities (fetch, handle/identity/DID resolvers, simple-store, pipe, xrpc-utils).\n- [services/{pds,bsky,bsync,ozone}](services/) — thin runtime wrappers; the actual implementation code lives in `packages/{pds,bsky,bsync,ozone}`.\n- [lexicons/](lexicons/) — the canonical JSON Lexicon schemas for `com.atproto.*` and `tools.ozone.*` **only** (this repo is their source of truth). Other namespaces (`app.bsky.*`, `chat.bsky.*`, …) are vendored per-package from the `bsky` repo or the network — see [Codegen](#codegen). [lexicons-overrides/](lexicons-overrides/) holds local override documents shared across packages.\n- [interop-test-files/](interop-test-files/) — language-neutral protocol conformance fixtures, copied from [bluesky-social/atproto-interop-tests](https://github.com/bluesky-social/atproto-interop-tests/) (their canonical source, shared across SDKs). Don't edit unless changing protocol-level behavior; any edit must also be contributed upstream.\n\n## Common commands\n\nWhole-repo verification commands (from root):\n\n```bash\n# build:tooling → prebuild (codegen) → build:ts → build:ui\npnpm run build [--force]\n\n# TypeScript-only build & typecheck\npnpm run build:ts [--force]\n\n# force code generation (lexicons, protobuf, i18n, utils, etc.)\npnpm run codegen\n\n# style + lint\npnpm run verify\npnpm run style:fix # Avoid, prefer per-file formatting & linting\n\n# lint specific files only\npnpm exec eslint --fix <path>\n\n# format specific files only\npnpm exec prettier --write <path>\n```\n\nPer-package work — **always run from inside the package directory**, not from the root:\n\n```bash\ncd packages/<pkg>\npnpm run build\npnpm run test\n```\n\nEvery package ships a `tsconfig.build.json` (composite, with explicit `references` to its workspace deps), and nearly every package with tests adds a `tsconfig.test.json` for the test sources. A package with root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, `lingui.config.ts`, `bin.js`, …) also adds a `tsconfig.config.json` so those files are type-checked — it extends [tsconfig/config.tsconfig.json](tsconfig/config.tsconfig.json) and `include`s the package's own `./*.{ts,js,cjs,mjs}`. When you add such a file to a package that has none, create `tsconfig.config.json` and reference it from the package `tsconfig.json` (`exclude` any root file that pulls `./src` or `./tests` into the program, e.g. a `jest.setup.ts`). The root `tsconfig.json` is a project-graph aggregator only.\n\nAvoid `pnpm run style:fix` (whole-repo prettier) unless the user explicitly asks for a repo-wide formatting pass.\n\nRun the formatter/linter once the work is complete: when about to commit, or when the user says the change is done, not after every small edit.\n\n## Tests\n\nFor Ozone integration tests, verify state through existing endpoints (for example, `queryEvents` for appeal events and `queryReports` for report state). Use direct database access only for fixtures that cannot be created through an API, or for tests specifically exercising database behavior.\n\nBefore writing or extending any test, invoke the `testing` skill ([.agents/skills/testing/SKILL.md](.agents/skills/testing/SKILL.md)). It covers runner selection (vitest vs jest), file layout, and tsconfig setup. For browser-driven UI tests, or for demoing/debugging the OAuth flows or the Account Manager interface, invoke the `playwright` skill ([.agents/skills/playwright/SKILL.md](.agents/skills/playwright/SKILL.md)) instead.\n\n## Codegen\n\nThe repo-root [lexicons/](lexicons/) holds the canonical `com.atproto.*` / `tools.ozone.*` schemas **only** — this repo is their source of truth. Every other namespace (`app.bsky.*`, `chat.bsky.*`, `site.standard.*`, …) is vendored **per package**: each package lists the NSIDs it needs and where to resolve them (the root `lexicons` directory, the `bsky` GitHub repo, the network, or the shared [lexicons-overrides/](lexicons-overrides/)) in its own committed `lexicons.json`. A package's `prebuild` runs `lex install --ci` (resolving JSON into its gitignored `./lexicons`) then `lex build` (generating the gitignored `./src/lexicons` TypeScript tree that code imports). **Full how-to: the [\"Working with lexicons\"](CONTRIBUTING.md#working-with-lexicons) section of CONTRIBUTING.md and the `lex-setup` skill.**\n\n- **Add/refresh an external lexicon in a package:** `cd packages/<pkg> && pnpm lex:install <nsid>` (adds it plus transitive deps); `pnpm lex:update` refreshes installed ones.\n- **Edit a canonical lexicon:** change it under [lexicons/](lexicons/), then run `pnpm lex:update-local` from the repo root to propagate the new CID into every dependent package's `lexicons.json`. (After editing a `.proto` file instead, run `pnpm codegen` from the root.)\n\nFor working with the generated SDK, invoke the focused skills under [.agents/skills/](.agents/skills/): `lex-setup` (lexicons.json / resolvers / codegen wiring), `lex-schema` and `lex-data` (schemas and values), `lex-client` (calls out), `xrpc-server` (defining server routes), and `lexification-client` / `lexification-server` (migrating off the legacy stack). To sync `chat.bsky.*` schemas from the chat repo, use `update-chat-lexicons`.\n\n## Architecture notes\n\n- **Lexicons are the contract.** Lexicon schemas drive both client types and server route validation. Service packages don't hand-write XRPC method signatures — they import the generated definitions from their `src/lexicons/` directory (gitignored; regenerated from the package's `lexicons.json` — see [Codegen](#codegen)).\n- ([packages/pds](packages/pds)) — a single-tenant atproto server: account management, repo storage (kysely-over-sqlite), actor storage (kysely-over-postgres), email, OAuth provider, blob storage. Runtime entry point is [services/pds](services/pds); production code is in `packages/pds/src`.\n  - Outbound requests to a URL resolved from a DID document are untrusted. Make them with `ctx.safeClient(url).xrpc(…)`, never a bare `xrpc(url, …)`, which uses the global fetch and bypasses the https-only/unicast-only checks. (`ctx.proxyAgent` is a separate thing — an undici `Dispatcher` used by pipethrough.)\n- ([packages/repo](packages/repo)) — **Every** MST traversal that can visit more than one child of a node (full-tree walks, diffs, block collection) MUST call `node.markVisited(seen)` on each node it enters, with a fresh `CidSet` per traversal. Never add one that skips it. Single-path lookups are exempt. See [packages/repo/AGENTS.md](packages/repo/AGENTS.md).\n- ([packages/bsky](packages/bsky)) — read-side service for `app.bsky.*` queries (timelines, profiles, feed generators, hydration pipeline, GraphQL-like view composition). Talks to PDSes via XRPC and to `bsync` via Connect-RPC (protobuf in `packages/bsky/proto`). Runtime entry point in [services/bsky](services/bsky).\n  - `app.bsky.feed.getFeed` forwards passthrough headers to a feed generator only when its record's service DID is in `bskyFeedgenDids`; Iris routing overrides do not grant permission.\n- ([packages/bsync](packages/bsync)) — internal service for cross-AppView synchronization (mutes, notifications). Connect-RPC interface.\n- ([packages/ozone](packages/ozone)) — moderation service for `tools.ozone.*`.\n  - Design viewer queries for hundreds of millions of moderation events and tens of millions of reports. Batch hydration, bound returned rows, and check query plans against existing indexes before proposing new ones. Keep viewer projections explicit and test ownership and private-field isolation through the API.\n  - Large report-table backfills run in the daemon as bounded primary-key batches. Lock a `job_cursor` row and commit each batch; schema migrations should add nullable columns only. After legacy report writers have rolled off, run `UPDATE job_cursor SET cursor = NULL WHERE job = 'report_reporter_did_backfill'` for a catch-up sweep. Advance past rows with missing or invalid source events and log their counts so they cannot stall the worker.\n- ([packages/dev-env](packages/dev-env)) — boots a full PDS + AppView + bsync + plc + ozone constellation in-process for tests and the `make run-dev-env` REPL. Most integration tests in `pds`/`bsky`/`ozone` use it as a fixture builder.\n\n## Conventions\n\n**Code style rules live in [STYLE_GUIDE.md](./STYLE_GUIDE.md)** — imports, typing, dependencies, change scope, and formatting. Read it before writing code. The rest of this section covers repository mechanics only.\n\n- Node ≥22 runtime floor; local nvm users default to Node 24 (`.nvmrc`). CI changeset/build/verify jobs read `devEngines.runtime.version` from `package.json`; tests stay on Node 22 for compatibility, with a security-patched minimum in `.github/workflows/repo.yaml`. Use `node --enable-source-maps` for production-style runs.\n- TypeScript compilation uses the native TS7 `tsc` (the standard `typescript` package). There is no per-package `typescript` devDependency — it is hoisted at the root. Note TS7 has no stable programmatic API yet; tools needing one must pin TS6.\n- **Every package touched by a change needs a changeset entry.** Add a file under [.changeset/](.changeset/) listing each modified package with an appropriate bump level (pre-v1 breaking changes are `minor` and everything else `patch`, post-v1 `major` for breaking changes, `minor` for new public API, `patch` otherwise). Dependency-only bumps are generated automatically — don't list them by hand. Create that file with `pnpm changeset`.\n- **Never buffer an unbounded stream from outside the process.** Decoding or buffering a request/response body that did not originate locally requires an explicit size bound on the _decoded_ bytes — a wire-size cap does not bound a compressed payload. Pass `maxSize` (and a `maxSizeError` for the caller's error type) to `createDecoders` and `pipeline` the result — it bounds every decoding stage, and must be used even when there is no content-encoding (see `packages/xrpc-server/src/util.ts`) — rather than passing a decoded stream straight to `streamToNodeBuffer`.\n\n## Agent files\n\nAgent files — this `AGENTS.md`, the skills under [.agents/skills/](.agents/skills/), and any package-level equivalents — are part of the codebase and must stay in sync with it.\n\n- Keep working plans, handoffs, review reports, query-plan captures, and other session scratch files outside the repository, such as in the workspace directory containing the checkout. Do not add repository ignore rules for them. Never commit them. Before committing, inspect the staged file list for working documents; put only durable project guidance in tracked documentation.\n\n- These files capture **important architectural patterns and conventions** — the non-obvious rules, invariants, and cross-cutting decisions an expert needs that are _not_ evident from reading the code itself. They are not a changelog of behavior, and must not restate anything a reader can infer directly from the code (a route's signature, a function's arguments, what a module exports). Most changes need no update: don't document a new route, metric, migration, or test that simply re-applies an existing pattern. Reach for an edit only when a change establishes, meaningfully alters, or removes such an architectural pattern, and prefer doing it when wrapping up rather than mid-task. If you do, update the file that owns it (repo-wide → this file or [STYLE_GUIDE.md](./STYLE_GUIDE.md); scoped to one package → that package's nested `AGENTS.md`; a workflow an agent follows → the relevant skill), and delete guidance a change makes obsolete in the same pass — stale guidance is worse than none. Reviewers check for drift as a backstop (see [.github/claude-review-prompt.md](.github/claude-review-prompt.md)).\n- Keep them **as concise as possible**: only the minimal directives an expert needs. No tutorials, no restating what the code already says.\n\n## Troubleshooting\n\n- **Integration tests run stale service code.** `@atproto/dev-env` loads services through their package exports from `dist`. After changing service code, run `pnpm run build` inside that service package before rerunning its integration tests.\n- **Stale codegen.** If the build fails due to a generated file in [packages/api](packages/api) being out of date, run `pnpm run codegen && pnpm run build` from that package, then re-run the build. This is only needed there because its `prebuild` step skips codegen as a performance optimization.\n- **Codegen ran but produced stale output.** Codegen relies on `pnpm build:tooling` to build the `@atproto/lex-cli` and `@atproto/lex-builder` packages first. If you see a codegen failure, run `pnpm build:tooling` from the root, then re-run codegen.\n- **End-to-end test fails with stale infra.** If docker containers persist across test runs, reset them with `cd packages/dev-infra && docker compose down --volumes`.\n- **Nothing else worked.** `make clean` wipes every installed dependency (`node_modules`), build artifact (`dist`, `*.tsbuildinfo`), and prebuild/codegen output across all packages; follow it with `pnpm install && pnpm run build` to restore a clean state.\n","isInternal":false,"tokens":3482,"sizeBytes":14476},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\n@AGENTS.md\n","isInternal":false,"tokens":11,"sizeBytes":24},{"name":"SKILL.md","path":".agents/skills/lex-client/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lex-client/SKILL.md","title":"lex-client","category":"anthropic-skill","format":"markdown","content":"---\nname: lex-client\ndescription: >\n  Call AT Protocol XRPC services with the `Client` from `@atproto/lex`. Use\n  when making an authenticated or unauthenticated HTTP request to an XRPC\n  service, wiring one service to call another, logging in with OAuth or an app\n  password, reading or writing repository records, uploading or fetching blobs,\n  configuring headers, labelers, service proxying, retries, or request/response\n  validation, handling XRPC errors, or composing reusable client actions. Also\n  use when replacing `AtpAgent`, `XrpcClient`, or a hand-rolled `fetch` call to\n  an `/xrpc/` endpoint. For a full migration off the legacy client stack, use\n  the lexification-client skill.\ndisable-model-invocation: false\n---\n\n# high-level XRPC client\n\n`Client` wraps an authenticated (or anonymous) session with helpers for AT Proto\nrepo operations (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`applyWrites`, `uploadBlob`, `getBlob`) plus the low-level\n`call`/`xrpc`/`xrpcSafe` request methods. Reach for it whenever there is a\nsession to carry, or per-service config (headers, labelers, proxy target) worth\nencapsulating.\n\nThe standalone `xrpc()` / `xrpcSafe()` exports exist for one-off, session-less\ncalls. Their first argument is an `Agent | AgentOptions` — a URL string, `URL`,\n`AgentConfig` object, or bare `FetchHandler` — and they otherwise share the same\noptions, responses, and errors as the client methods.\n\n```ts\nimport { xrpc } from '@atproto/lex'\n\nawait xrpc(hostUrl, com.atproto.sync.requestCrawl, { body: { hostname } })\n```\n\n## Constructing\n\nThe constructor takes an `Agent` (anything with `fetchHandler`, optionally\n`did`) or `AgentOptions` (a URL string/`URL`, or an `AgentConfig`).\n\n```ts\nimport { Client } from '@atproto/lex'\n\n// Unauthenticated\nconst client = new Client('https://public.api.bsky.app')\n\n// OAuth — OAuthSession implements Agent\nimport { type OAuthSession } from '@atproto/oauth-client-node'\nconst session: OAuthSession = await oauthClient.restore(userDid)\nconst client = new Client(session)\n```\n\n### App password sessions (CLI / scripts / bots)\n\n`PasswordSession` also implements `Agent`, plus `AsyncDisposable` — prefer\n`await using` so the session is deleted server-side on scope exit.\n\n```ts\nimport {\n  LexAuthFactorError,\n  PasswordSession,\n} from '@atproto/lex-password-session'\n\n// Credentials as a single URL: https://<handle>:<app-password>@<pds-host>\nawait using session = await PasswordSession.login(\n  process.env.APP_PASSWORD_CREDENTIALS,\n)\nconst client = new Client(session)\n```\n\nLong-lived apps should persist session data via `onUpdated` and use\n`PasswordSession.resume(data, options)` on the next run rather than re-sending\ncredentials. Other statics: `createAccount(body, { service, ... })` and\n`delete(data)` (revoke stored session data without resuming it). `login` throws\n`LexAuthFactorError` when the account requires 2FA — catch it, prompt, and retry\nwith `authFactorToken`.\n\n### Server-side calls with a static credential\n\nNo session needed — put the credential in the constructor headers. Internal\nservices typically also relax response processing:\n\n```ts\nconst client = new Client(\n  {\n    service: config.serviceUrl,\n    headers: { authorization: `Bearer ${config.serviceApiKey}` },\n  },\n  // Trust internal services to send well-formed responses\n  { strictResponseProcessing: false, validateResponse: config.devMode },\n)\n```\n\nIf the credential or URL must be resolved per-request, supply a custom agent:\n\n```ts\nconst agent: Agent = {\n  get did() {\n    return undefined\n  },\n  async fetchHandler(path, init) {\n    const { serviceUrl, serviceApiKey } = await getConfig()\n    const headers = new Headers(init?.headers)\n    headers.set('authorization', `Bearer ${serviceApiKey}`)\n    return fetch(new URL(path, serviceUrl), { ...init, headers })\n  },\n}\n\nconst client = new Client(agent)\n```\n\n`AgentConfig` and `fetchHandler` are mutually exclusive — passing both throws a\n`TypeError`.\n\n### Service proxying, and one client for several services\n\n`service` and `labelers` set the `atproto-proxy` / `atproto-accept-labelers`\nheaders. Both are _defaults_ and are overridable per request, so a single\nAppView-configured client can still reach the user's PDS directly — pass `null`\nto suppress the header for that request.\n\n```ts\nconst client = new Client(session, {\n  service: 'did:web:api.bsky.app#bsky_appview',\n  labelers: ['did:plc:labeler1', 'did:plc:labeler2'],\n})\n\n// Proxied to the AppView (client default)\nawait client.call(app.bsky.feed.getTimeline)\n\n// Straight to the user's PDS\nawait client.xrpc(com.atproto.repo.getRecord, {\n  service: null,\n  labelers: null,\n  appLabelers: null,\n  params: { repo: client.assertDid, collection, rkey: 'self' },\n})\n```\n\nThe record helpers (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`*Record`, `applyWrites`, `uploadBlob`, `getBlob`) already default to\n`service: null` / `labelers: null`, since repo operations belong on the PDS. Set\nthem explicitly to proxy one.\n\n## Validation options\n\n```ts\nconst client = new Client(session, {\n  // Validate request bodies against the input schema before sending. Worth\n  // enabling in dev/test to catch schema mistakes at the source.\n  validateRequest: true, // default: false\n\n  // Validate response bodies against the output schema.\n  validateResponse: false, // default: true\n\n  // Strict Lex decoding. `false` accepts legacy blobs, datetimes without\n  // timezones, etc. — see the lex-schema skill.\n  strictResponseProcessing: false, // default: true\n})\n```\n\nAll three are overridable per-call on `call` / `xrpc` / `xrpcSafe`.\n\n## Authentication accessors\n\n```ts\nclient.did // DidString | undefined\nclient.assertAuthenticated() // throws if unauthenticated; narrows client.did\nclient.assertDid // DidString (throws if unauthenticated)\n```\n\n`assertAuthenticated()` is an assertion function, so TypeScript requires the\nreceiver to have an explicit type annotation. `const client = new Client(...)`\n(inferred) will fail with TS2775 — write `const client: Client = new Client(...)`\nor annotate the parameter.\n\n## Requests\n\n### `client.call()` — returns the body, throws on failure\n\n```ts\nimport { app } from './lexicons/index.js'\n\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: 'pfrazee.com',\n})\n\nawait client.call(app.bsky.feed.sendInteractions, { interactions })\n\nconst timeline = await client.call(\n  app.bsky.feed.getTimeline,\n  { limit: 50 },\n  { signal, maxRetries: 3, headers: { 'custom-header': 'value' } },\n)\n```\n\nThe input argument is positional and distinct from the options: for a query it\nis the `params`, for a procedure the body, for an action the input. Methods that\ntake neither can be called with the schema alone, but **the options must never\noccupy the input slot** — `client.call(schema, options)` sends `options` as\nquery parameters and fails validation. Use `client.call(schema, {}, options)`,\nor `client.xrpc(schema, options)`.\n\n### `client.xrpc()` — full response\n\n```ts\nconst response = await client.xrpc(app.bsky.feed.getTimeline, {\n  params: { limit: 50 },\n  signal,\n  headers: { 'custom-header': 'value' },\n})\nresponse.status\nresponse.headers.get('content-language')\nresponse.body\n```\n\nUnlike `call`, `xrpc` takes a single options object with `params` / `body` as\nnamed fields, so options-only calls are unambiguous.\n\n### `client.xrpcSafe()` — discriminated result\n\nSame request as `xrpc()`, but returns the failure instead of throwing:\n\n```ts\nconst result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {\n  params: { handle: 'alice.bsky.social' },\n})\nif (result.success) {\n  result.body\n} else if (result.matchesSchemaErrors()) {\n  result.error // narrowed to the errors declared by the schema\n} else {\n  throw result.reason\n}\n```\n\n### Errors\n\nAll failures extend `XrpcError` (`error`, `message`, `matchesSchemaErrors()`,\n`shouldRetry()`). The three concrete branches of `XrpcFailure`:\n\n| Class                      | Meaning                                                                 |\n| -------------------------- | ----------------------------------------------------------------------- |\n| `XrpcResponseError`        | Server returned a non-2xx status                                        |\n| `XrpcInvalidResponseError` | Response is non-compliant — unexpected status, wrong encoding, bad data |\n| `XrpcInternalError`        | Failed before a usable response existed (network, client)               |\n\nSubclasses worth narrowing on: `XrpcAuthenticationError` (401, exposes\n`wwwAuthenticate`), `XrpcResponseValidationError` (response failed schema\nvalidation), and `XrpcFetchError` (the underlying `fetch` threw — the retryable\ninternal error).\n\nOnly `XrpcResponseError` carries HTTP data. `status`, `headers`, and `body` do\n**not** exist on the `XrpcFailure` union, so narrow first:\n\n```ts\nif (!result.success && result instanceof XrpcResponseError) {\n  result.status\n  result.headers.get('retry-after')\n}\n```\n\n### Retries\n\n`call`, `xrpc`, `xrpcSafe`, and the standalone functions all retry internally.\nSet `maxRetries` (default `0`); tune with `minRetryTimeout` (500ms),\n`maxRetryTimeout` (30s), `retryTimeoutFactor` (2), `retryHeaders` (honour\n`Retry-After` / `RateLimit-Reset`, default `true`), or replace the predicate with\n`retry(failure, { counter })`. The defaults already cover the retryable status\ncodes (408, 425, 429, 5xx) and network errors, so do not wrap calls in a manual\nretry loop.\n\n## Records\n\n### `create` / `get` / `put` / `delete`\n\n```ts\nimport { currentDatetimeString } from '@atproto/lex'\n\nconst { uri, cid } = await client.create(app.bsky.feed.post, {\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\n\n// Literal-key records (profile is rkey 'self') need no rkey\nconst profile = await client.get(app.bsky.actor.profile)\nprofile.value.displayName\n\nconst post = await client.get(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n\nawait client.put(app.bsky.actor.profile, { displayName: 'New Name' })\nawait client.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n```\n\n`rkey` is optional for `literal:` and auto-generated (`tid`) keys and required\notherwise; the type system enforces this, so a missing `rkey` shows up as a\ncompile error rather than a runtime one. Shared options: `validate` (ask the PDS\nto validate), `validateRequest` (validate locally before sending), `swapCommit`\n(optimistic concurrency on the commit CID), and for `put`/`delete` also\n`swapRecord` (on the record's own CID).\n\n### `list` / `listAll` — paginating a collection\n\nListed records are a discriminated union on `valid`: entries that fail schema\nvalidation still come back, with `value` widened to `LexMap`. Check `valid`\nbefore touching typed fields.\n\n```ts\nconst page = await client.list(app.bsky.feed.post, { limit: 50, reverse: true })\nfor (const record of page.records) {\n  if (record.valid) console.log(record.uri, record.value.text)\n}\nif (page.cursor) {\n  await client.list(app.bsky.feed.post, { cursor: page.cursor, limit: 50 })\n}\n```\n\n`listAll()` is the auto-paginating async generator — prefer it over hand-rolling\na cursor loop. It defaults to `maxRetries: 3` since a long walk is far more\nlikely to hit a transient failure:\n\n```ts\nfor await (const record of client.listAll(app.bsky.feed.post)) {\n  if (record.valid) console.log(record.value.text)\n}\n```\n\n### `applyWrites` — atomic batch writes\n\nAll ops succeed or fail together. Options: `repo` (defaults to `client.did`),\n`validate`, `swapCommit`.\n\n```ts\nconst response = await client.applyWrites((op) => [\n  op.create(app.bsky.feed.post, {\n    text: 'Hello!',\n    createdAt: currentDatetimeString(),\n  }),\n  op.update(app.bsky.actor.profile, { displayName: 'Alice' }),\n  op.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' }),\n])\n```\n\n`response.body.results` is **optional**, and is a union in which `deleteResult`\nhas no `uri` or `cid`. Narrow before reading them:\n\n```ts\nfor (const result of response.body.results ?? []) {\n  if (com.atproto.repo.applyWrites.createResult.$isTypeOf(result)) {\n    console.log(result.uri, result.cid)\n  }\n}\n```\n\nThe callback may also be a generator, which reads better for dynamic batches:\n\n```ts\nawait client.applyWrites(function* (op) {\n  for (const rkey of staleRkeys) yield op.delete(app.bsky.feed.post, { rkey })\n})\n```\n\n### Blobs\n\n```ts\nconst { body } = await client.uploadBlob(bytes, { encoding: 'image/png' })\nbody.blob // BlobRef — store this in a record\n\nconst image = await client.getBlob(did, cid)\n```\n\n## Labelers\n\n```ts\n// Process-global defaults, always sent with `;redact`\nClient.configure({ appLabelers: ['did:plc:labeler1'] })\n\n// Per-client, and mutable at runtime\nconst client = new Client(session, { labelers: ['did:plc:labeler3'] })\nclient.addLabelers(['did:plc:labeler4'])\nclient.setLabelers(['did:plc:labeler5'])\nclient.clearLabelers()\n```\n\nPass `appLabelers: null` on a request to drop the process-global ones. The usual\npost-login flow reads the user's preference:\n\n```ts\nconst { preferences } = await client.call(app.bsky.actor.getPreferences)\nconst pref = preferences.findLast(app.bsky.actor.defs.labelersPref.$isTypeOf)\nclient.setLabelers(pref?.labelers.map((l) => l.did) ?? [])\n```\n\n## Actions — composable client operations\n\nAn `Action<Input, Output>` is `(client, input, options) => Output | Promise<Output>`.\n`client.call()` accepts one anywhere a schema fits, so an action is\ninterchangeable with a lexicon method at the call site.\n\n```ts\nimport { type Action, Client, currentDatetimeString } from '@atproto/lex'\nimport { app } from './lexicons/index.js'\n\ntype LikeInput = { uri: AtUriString; cid: CidString }\n\nexport const likePost: Action<LikeInput, CreateOutput> = async (\n  client: Client, // explicit — required by assertAuthenticated() below\n  { uri, cid },\n  options,\n) => {\n  client.assertAuthenticated()\n  return client.create(\n    app.bsky.feed.like,\n    { subject: { uri, cid }, createdAt: currentDatetimeString() },\n    options,\n  )\n}\n\nawait client.call(likePost, { uri, cid })\n```\n\nActions compose — thread `options` through so cancellation propagates:\n\n```ts\nconst upsertPreference: Action<Preference, Preference[]> = async (\n  client,\n  pref,\n  options,\n) => {\n  const { preferences } = await client.call(\n    app.bsky.actor.getPreferences,\n    {},\n    options,\n  )\n  options?.signal?.throwIfAborted()\n  const updated = [...preferences.filter((p) => p.$type !== pref.$type), pref]\n  await client.call(\n    app.bsky.actor.putPreferences,\n    { preferences: updated },\n    options,\n  )\n  return updated\n}\n```\n\nExport actions individually rather than as one object, so consumers tree-shake\nwhat they don't call:\n\n```ts\n// actions.ts\nexport const post: Action</* ... */> = async (c, i, o) => {/* ... */}\nexport const follow: Action</* ... */> = async (c, i, o) => {/* ... */}\n\n// usage\nimport * as actions from './actions.js'\nawait client.call(actions.post, { text: 'Hello!' })\n```\n\nGuidelines: annotate as `Action<Input, Output>` (it contextually types the\nparameters); call `client.assertAuthenticated()` when auth is required;\n`options?.signal?.throwIfAborted()` between long steps; and handle swap-error\nretries here rather than pushing optimistic-concurrency handling onto callers.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  every client method, and `$isTypeOf` / `$build` / `$type`.\n- **[lex-data](../lex-data/SKILL.md)** — blobs, CIDs, branded strings, and\n  datetimes at the call boundary.\n- **[lexification-client](../lexification-client/SKILL.md)** — migrating from\n  `AtpAgent` / `XrpcClient`.\n- **[xrpc-server](../xrpc-server/SKILL.md)** — the other half, when the service\n  also _serves_ XRPC routes.\n- **[lex-setup](../lex-setup/SKILL.md)** — installing lexicons and configuring\n  the codegen that produces `./lexicons/index.js`.\n","frontmatter":{"name":"lex-client","description":"Call AT Protocol XRPC services with the `Client` from `@atproto/lex`. Use when making an authenticated or unauthenticated HTTP request to an XRPC service, wiring one service to call another, logging in with OAuth or an app password, reading or writing repository records, uploading or fetching blobs, configuring headers, labelers, service proxying, retries, or request/response validation, handling XRPC errors, or composing reusable client actions. Also use when replacing `AtpAgent`, `XrpcClient`, or a hand-rolled `fetch` call to an `/xrpc/` endpoint. For a full migration off the legacy client stack, use the lexification-client skill.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":3979,"sizeBytes":15859},{"name":"SKILL.md","path":".agents/skills/lex-data/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lex-data/SKILL.md","title":"lex-data","category":"anthropic-skill","format":"markdown","content":"---\nname: lex-data\ndescription: >\n  Represent, parse, serialize, hash, or convert AT Protocol data values with\n  `@atproto/lex`. Use whenever code touches CIDs, raw bytes, `$link` / `$bytes`\n  JSON, CBOR / DRISL encoding, blob references, or AT Protocol identifier and\n  datetime strings — DIDs, handles, AT URIs, NSIDs, TIDs, record keys,\n  `createdAt` / `indexedAt`. Applies when the ask sounds like \"serialize this\n  record\", \"hash these bytes\", \"why is this datetime rejected\", \"count the\n  characters in this field\", or when replacing `multiformats` CIDs,\n  `new Date().toISOString()`, or hand-rolled base64. Read it before typing a\n  DID, AT URI, or datetime as a bare `string`.\ndisable-model-invocation: false\n---\n\n# Data model: types, CIDs, JSON, CBOR, identifier strings, blobs\n\nThe AT Protocol data model is JSON plus two primitives — **CIDs**\n(content-addressed links) and **bytes** (raw binary) — encodable as JSON or as\nCBOR.\n\n## Which package exports what\n\n`@atproto/lex` re-exports `lex-data`, `lex-json`, `lex-schema` and\n`lex-client`, so a package that already depends on it needs no extra import\npath. `@atproto/lex-cbor` is **not** re-exported — depend on it explicitly.\n\n| Symbols                                                       | Defined in                                              | Reachable via `@atproto/lex` |\n| ------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------- |\n| `LexValue`, `Cid`, blob refs, base64/utf8 helpers, `LexError` | `@atproto/lex-data`                                     | yes                          |\n| `lexParse`, `lexStringify`, `jsonToLex`, `lexToJson`          | `@atproto/lex-json`                                     | yes                          |\n| Identifier & datetime string types + guards                   | `@atproto/syntax`, re-exported by `@atproto/lex-schema` | yes                          |\n| `encode`, `decode`, `decodeAll`, `cidForLex`                  | `@atproto/lex-cbor`                                     | **no**                       |\n\nLow-level packages (`repo`, `common`, `xrpc-server`) import `@atproto/lex-data`\ndirectly to avoid pulling in the client and schema layers.\n\n> [!NOTE]\n> This repo sets `verbatimModuleSyntax: true`, so type-only names need the\n> `type` modifier — `import { type DidString, isDidString } from '@atproto/lex'`.\n> All examples below follow that form.\n\n## Lex value types\n\n```ts\nimport {\n  type LexValue,\n  type TypedLexMap,\n  isLexValue,\n  isTypedLexMap,\n} from '@atproto/lex'\n\n// LexScalar:    number (integers only) | string | boolean | null | Cid | Uint8Array\n// LexValue:     LexScalar | LexValue[] | { [key: string]?: LexValue }\n// LexMap:       { [key: string]?: LexValue }\n// TypedLexMap:  LexMap & { $type: string }\n```\n\n`isLexValue`, `isLexMap`, `isLexArray` and `isLexScalar` take `unknown`;\n`isTypedLexMap` takes a `LexValue`, so narrow with `isLexValue` first when the\ninput is untyped. `isLexValue` walks the whole structure and rejects cycles,\nnon-integer numbers, and non-plain objects (`Date`, class instances,\nfunctions) — it is not a cheap check, so avoid it on hot paths where a schema\nalready validates the value.\n\n## CIDs\n\n`Cid` is an interface, not a class. `@atproto/lex-data` defines it rather than\nre-exporting `multiformats`' `CID`, because that class breaks under\n`node16` / `bundler` module resolution. Treat `CID` and `asMultiformatsCID` as\ndeprecated escape hatches.\n\n```ts\nimport {\n  type Cid,\n  asCid,\n  decodeCid,\n  ifCid,\n  isCid,\n  parseCid,\n  parseCidSafe,\n} from '@atproto/lex'\n\nparseCid(str) // Cid — throws on invalid\nparseCidSafe(str) // Cid | null\ndecodeCid(bytes) // Cid from binary form — throws on invalid\nisCid(value) // type guard\nifCid(value) // Cid | null\nasCid(value) // Cid — throws on invalid\n```\n\nEach accepts a `{ flavor }` option that additionally constrains the CID, which\nis how you enforce that a blob CID really is a blob CID rather than trusting\nthe caller:\n\n- `'raw'` — v1 + raw multicodec (blob content)\n- `'cbor'` — v1 + dag-cbor + sha256 (records, MST nodes)\n- `'dasl'` — either codec, v1 + sha256\n\nFor strings you already hold, `validateCidString(str, options)` returns a\nboolean and also checks the string is the canonical encoding;\n`ensureValidCidString` throws instead.\n\nCreating CIDs — hashing is async because it goes through WebCrypto:\n\n```ts\nimport {\n  cidForCbor,\n  cidForRawBytes,\n  cidForRawHash,\n  isCidForBytes,\n} from '@atproto/lex'\nimport { cidForLex } from '@atproto/lex-cbor'\n\nawait cidForLex(record) // CBOR-encode then hash — the usual entry point\nawait cidForCbor(cborBytes) // hash bytes that are already CBOR\nawait cidForRawBytes(blobBytes) // raw CID for blob content\ncidForRawHash(sha256Digest) // sync, when you already streamed the hash\nawait isCidForBytes(cid, bytes) // verify a CID matches its content\n```\n\nIn Lex JSON, CIDs are encoded as `{ \"$link\": \"bafyrei...\" }`.\n\n## Bytes\n\nBinary data is `Uint8Array`; JSON-encoded as `{ \"$bytes\": \"base64...\" }`.\n`@atproto/lex-data` ships the byte utilities so packages don't add their own\nbase64 dependency: `toBase64` / `fromBase64` (both take an optional\n`'base64' | 'base64url'` alphabet), `utf8ToBase64` / `utf8FromBase64`,\n`utf8FromBytes`, `ui8Concat`, `ui8Equals`, `asUint8Array`, `ifUint8Array`.\n\n## JSON ↔ Lex\n\n| Function       | Direction         | Input           | Output                                |\n| -------------- | ----------------- | --------------- | ------------------------------------- |\n| `lexParse`     | JSON string → Lex | `string`        | `LexValue` (with `Cid`, `Uint8Array`) |\n| `lexStringify` | Lex → JSON string | `LexValue`      | `string`                              |\n| `jsonToLex`    | parsed JSON → Lex | plain JS object | `LexValue`                            |\n| `lexToJson`    | Lex → plain JS    | `LexValue`      | plain JS object                       |\n\n`lexParse<T>()` takes a type parameter, so no `as` cast is needed:\n\n```ts\nimport { lexParse, lexStringify } from '@atproto/lex'\n\nconst sub =\n  lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(json)\nconst out = lexStringify({ ref: someCid, data: someBytes })\n```\n\n`lexParse` and `jsonToLex` accept `{ strict }`, defaulting to **`false`**:\nmalformed `$link` / `$bytes` objects and non-integer numbers pass through\nunchanged rather than throwing. Pass `{ strict: true }` when the input is\nuntrusted and a silently-wrong value would be worse than an exception.\n`lexParseJsonBytes(bytes, options)` is the same parse starting from UTF-8\nbytes.\n\nField-level encode/decode is rarely needed, but note the return contract —\nthese signal failure by returning `undefined`, they do not throw:\n\n```ts\nimport {\n  encodeLexBytes,\n  encodeLexLink,\n  parseLexBytes,\n  parseLexLink,\n} from '@atproto/lex'\n\nparseLexLink({ $link: 'bafy...' }) // Cid | undefined\nparseLexBytes({ $bytes: 'SGVsbG8=' }) // Uint8Array | undefined\nencodeLexLink(someCid) // { $link: '...' }\nencodeLexBytes(new Uint8Array([1, 2, 3])) // { $bytes: '...' }\n```\n\n## CBOR (DRISL)\n\nUse CBOR for repo storage, signed records, CAR files, and event frames —\nanywhere [DRISL](https://dasl.ing/drisl.html) deterministic encoding is\nrequired. Add `@atproto/lex-cbor` as a dependency; it is not reachable through\n`@atproto/lex`.\n\n```ts\nimport { type LexValue } from '@atproto/lex'\nimport { cidForLex, decode, decodeAll, encode } from '@atproto/lex-cbor'\n\nconst bytes: Uint8Array = encode(someLexValue)\nconst value = decode<{ foo: LexValue }>(bytes)\n\n// Concatenated values (CAR blocks, subscription frames)\nfor (const frame of decodeAll(buffer)) {\n  /* … */\n}\n```\n\n`encode` throws on anything the AT data model forbids: non-string map keys,\n`undefined`, and non-integer numbers. That strictness is the point — it is what\nmakes the resulting CID stable.\n\n## Datetime strings\n\n`DatetimeString` is the branded type for AT Protocol datetimes.\n`Date.prototype.toISOString()` is not guaranteed to conform (years outside\n0–9999 serialize with a `±YYYYYY` prefix), so route `Date` → string through\nthe helpers rather than calling `toISOString()` directly:\n\n```ts\nimport {\n  type DatetimeString,\n  asDatetimeString,\n  currentDatetimeString,\n  ifDatetimeString,\n  isDatetimeString,\n  toDatetimeString,\n} from '@atproto/lex'\n\ncurrentDatetimeString() // now\ntoDatetimeString(date) // throws InvalidDatetimeError on an out-of-range date\nasDatetimeString(str) // validates + brands a string, throws on invalid\nisDatetimeString(value) // type guard\nifDatetimeString(value) // DatetimeString | undefined\n```\n\n`isDatetimeStringLenient` accepts ISO-ish strings that the spec rejects (e.g.\nmissing a timezone) — use it when reading legacy records, not when writing.\nFor repairing bad historical `createdAt` values, `normalizeDatetime` /\n`normalizeDatetimeAlways` live in `@atproto/syntax` only; they are not\nre-exported from `@atproto/lex`.\n\nUse `DatetimeString` on record and DB row types so a raw `string` can't drift\nin:\n\n```ts\nimport type { DatetimeString, DidString } from '@atproto/lex'\n\ninterface Row {\n  did: DidString\n  indexedAt: DatetimeString\n}\n```\n\n## Identifier strings\n\nNominal string types keep unvalidated `string`s from being passed where a\nvalidated identifier is expected.\n\n| Type                 | Format                     | Guard                  |\n| -------------------- | -------------------------- | ---------------------- |\n| `DidString`          | `did:method:specific-id`   | `isDidString`          |\n| `HandleString`       | DNS-style handle           | `isHandleString`       |\n| `AtIdentifierString` | DID **or** handle          | `isAtIdentifierString` |\n| `AtUriString`        | `at://…`                   | `isAtUriString`        |\n| `UriString`          | any `scheme:…` URI         | `isUriString`          |\n| `NsidString`         | `app.bsky.feed.post`       | `isNsidString`         |\n| `TidString`          | timestamp identifier       | `isTidString`          |\n| `RecordKeyString`    | rkey (TID or literal)      | `isRecordKeyString`    |\n| `CidString`          | CID in string form         | `isCidString`          |\n| `LanguageString`     | BCP-47 tag (`en`, `pt-BR`) | `isLanguageString`     |\n| `DatetimeString`     | AT Proto datetime          | `isDatetimeString`     |\n\nOnly `AtUriString`, `AtIdentifierString` and `DatetimeString` also ship\n`as…` / `assert…` / `if…` variants. For the rest, the generic format helpers\ncover the same ground:\n\n```ts\nimport { asStringFormat, ifStringFormat, isStringFormat } from '@atproto/lex'\n\nisStringFormat(value, 'did') // narrows to DidString\nasStringFormat(value, 'nsid') // throws TypeError on invalid\nifStringFormat(value, 'handle') // HandleString | undefined\n```\n\n`isAtUriString`, `isLanguageString` and `isStringFormat` accept\n`{ strict: false }` for a lenient pass that tolerates non-conforming\nreal-world data.\n\n> [!NOTE]\n> These types are defined in `@atproto/syntax` and re-exported through\n> `@atproto/lex`. Import from `@atproto/lex` where the package already depends\n> on it — that keeps identifier types and generated schemas coming from one\n> place and avoids adding a `@atproto/syntax` dependency.\n\n### Boundaries\n\nProtobuf messages, data-plane responses and Kysely rows all arrive as bare\n`string`. Brand them once at the boundary so downstream code is typed, instead\nof asserting at each use:\n\n```ts\ndids: dids as DidString[],\npost: { uri: item.uri as AtUriString, cid: item.cid || undefined },\n```\n\nAn `as` cast is appropriate where the value provably came from a validated\nsource (your own DB, a schema-validated response). Where it did not — env\nvars, CLI args, user input — run the guard: `assert(isDidString(serverDid))`.\n\n## Blob references\n\nTwo shapes coexist on the network:\n\n```ts\ntype TypedBlobRef = { $type: 'blob'; ref: Cid; mimeType: string; size: number }\ntype LegacyBlobRef = { cid: string; mimeType: string } // no $type, no size\ntype BlobRef = TypedBlobRef | LegacyBlobRef\n```\n\nNew uploads are always `TypedBlobRef` — the PDS `uploadBlob` endpoint returns\none. Reading code has to tolerate both, which is what the format-agnostic\naccessors are for:\n\n```ts\nimport {\n  type BlobRef,\n  enumBlobRefs,\n  getBlobCid,\n  getBlobCidString,\n  getBlobMime,\n  getBlobSize,\n  isLegacyBlobRef,\n  isTypedBlobRef,\n} from '@atproto/lex'\n\ngetBlobCid(blob) // Cid — parses the string for legacy refs, so it can throw\ngetBlobCidString(blob) // string — skips the parse, prefer it when you only need the string\ngetBlobMime(blob) // string\ngetBlobSize(blob) // number | undefined — legacy refs carry no size\n\n// Deep-walk a record for its blobs (what the PDS does on record write/import)\nfor (const ref of enumBlobRefs(record, { allowLegacy: true, strict: false })) {\n  /* … */\n}\n```\n\n`enumBlobRefs` skips legacy refs unless `allowLegacy: true`.\n\n`BlobRef` is an interface, so `instanceof` does not apply — use `isBlobRef` /\n`isTypedBlobRef` / `isLegacyBlobRef`.\n\n### What `strict` means for blobs\n\nThe word means different things at two layers, and conflating them causes\nconfusing validation failures:\n\n- On the **guards** (`isBlobRef`, `isTypedBlobRef`, `isLegacyBlobRef`,\n  `enumBlobRefs`), `strict` constrains the _CID flavor_. Default `true`\n  requires a raw v1 sha256 CID; `strict: false` accepts any CID (and tolerates\n  `size: -1`). It does not reject the legacy shape — `isBlobRef` accepts legacy\n  refs either way.\n- On **schema validation** (`l.blob()`), `strict: true` (the default) rejects\n  legacy refs outright and enforces `accept` / `maxSize`; `strict: false`\n  admits them and skips those checks.\n\nA `Client` constructed with `strictResponseProcessing: false` threads\n`strict: false` through both response parsing and schema validation, which is\nhow you read a server that still emits legacy refs.\n\n## String lengths\n\nLexicon string constraints are measured in graphemes (user-perceived\ncharacters) or UTF-8 bytes, never in JS string length:\n\n```ts\nimport { graphemeLen, utf8Len } from '@atproto/lex'\n\ngraphemeLen('👨‍👩‍👧‍👦') // 1\nutf8Len('👨‍👩‍👧‍👦') // 25\n```\n\n## Comparison\n\n`lexEquals(a, b)` deep-compares two `LexValue`s, handling `Cid` and\n`Uint8Array` where `===` and `JSON.stringify` would not.\n\n## Related skills\n\n- [lex-schema](../lex-schema/SKILL.md) — validating these values against\n  Lexicon schemas.\n- [lex-setup](../lex-setup/SKILL.md) — adding `@atproto/lex-cbor` and the rest\n  of the package family to a package.\n- [lexification-client](../lexification-client/SKILL.md) — migrating off\n  `multiformats` `CID`, the `BlobRef` class from `@atproto/lexicon`, and\n  `jsonStringToLex` / `stringifyLex`.\n","frontmatter":{"name":"lex-data","description":"Represent, parse, serialize, hash, or convert AT Protocol data values with `@atproto/lex`. Use whenever code touches CIDs, raw bytes, `$link` / `$bytes` JSON, CBOR / DRISL encoding, blob references, or AT Protocol identifier and datetime strings — DIDs, handles, AT URIs, NSIDs, TIDs, record keys, `createdAt` / `indexedAt`. Applies when the ask sounds like \"serialize this record\", \"hash these bytes\", \"why is this datetime rejected\", \"count the characters in this field\", or when replacing `multiformats` CIDs, `new Date().toISOString()`, or hand-rolled base64. Read it before typing a DID, AT URI, or datetime as a bare `string`.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":3926,"sizeBytes":14754},{"name":"SKILL.md","path":".agents/skills/lex-schema/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lex-schema/SKILL.md","title":"lex-schema","category":"anthropic-skill","format":"markdown","content":"---\nname: lex-schema\ndescription: >\n  Validate, parse, construct, and type AT Protocol data against Lexicon schemas\n  with `@atproto/lex` / `@atproto/lex-schema`. Use whenever code reaches into a\n  generated `./lexicons/` tree — `$parse`, `$safeParse`, `$validate`,\n  `$matches`, `$assert`, `$build`, `$isTypeOf`, `$type`, `$nsid`, `$lxm`,\n  `$Params`, `$Input`, `$Output`, `$Message`, `.Main` — when checking whether an\n  unknown record or XRPC payload conforms, when loosening checks with\n  `strict: false`, when declaring a schema by hand with the `l` builder, when\n  handing a Lexicon schema to a Standard Schema consumer, or when replacing\n  Zod/Ajv-style validation with Lexicon validation. Not for codegen setup (see\n  lex-setup) nor for the value types themselves — CIDs, bytes, blobs, JSON/CBOR\n  encoding, branded strings (see lex-data).\ndisable-model-invocation: false\n---\n\n# Generated schemas and the `l` builder\n\n`lex build` compiles each Lexicon document into a TypeScript module carrying\nboth a runtime schema and its types, addressed by NSID dot-path through the\nnamespace tree:\n\n```ts\nimport { app, com } from './lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def schema\ncom.atproto.repo.createRecord // query/procedure schema\n```\n\nThe tree lives in `./src/lexicons/` (plural), is gitignored, and is regenerated\nby `lex build` — never edit it. See [lex-setup](../lex-setup/SKILL.md).\n\n## Accessors on a generated namespace\n\n| Accessor                | Schema kind             | What it is                                          |\n| ----------------------- | ----------------------- | --------------------------------------------------- |\n| `$nsid`                 | all                     | NSID of the document (`'app.bsky.feed.post'`)       |\n| `$type`                 | record / typed object   | `$type` string; equals `$nsid` for `main`           |\n| `$lxm`                  | query / procedure / sub | XRPC method ID — auth checks, proxy routing         |\n| `$params` / `$Params`   | query / procedure / sub | Params schema / its parsed type                     |\n| `$input` / `$Input`     | procedure               | Request payload schema / `{ encoding, body }` type  |\n| `$InputBody`            | procedure               | Request body type alone                             |\n| `$output` / `$Output`   | query / procedure       | Response payload schema / `{ encoding, body }` type |\n| `$OutputBody`           | query / procedure       | Response body type alone                            |\n| `$message` / `$Message` | subscription            | Message schema / message type                       |\n| `.Main`                 | record / object         | The main definition's TS type                       |\n\n`$Input` / `$Output` / `$InputBody` / `$OutputBody` take an optional binary-body\ntype parameter (`$Output<Buffer>`); the default is the opaque `l.BinaryData`\nplaceholder.\n\nSub-definitions sit alongside `.Main` — type in PascalCase, schema in camelCase:\n\n```ts\ntype ReplyRef = app.bsky.feed.post.ReplyRef\napp.bsky.feed.defs.postView // schema\ntype PostView = app.bsky.feed.defs.PostView // type\n```\n\n**Never write NSID string literals.** Use `$type` / `$lxm` / `$token`: the\nschema stays the single source of truth and each constant is one shared string\ninstance (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md#lexicons)). Tokens are\nalso available as PascalCase export (e.g., `app.bsky.feed.defs.RequestLess`),\nprefer this over the `$token` notation.\n\n## Validation methods\n\nEvery schema exposes these as plain methods; generated `main` definitions also\nre-export them as pre-bound `$`-prefixed consts, so\n`const { $parse } = app.bsky.feed.post` and\n`export const validateStrongRef = com.atproto.repo.strongRef.$safeValidate`\nboth work.\n\n| Method                       | On invalid  | Transforms input?      | Returns                        |\n| ---------------------------- | ----------- | ---------------------- | ------------------------------ |\n| `$parse(data, opts?)`        | throws      | yes                    | parsed value (output type)     |\n| `$safeParse(data, opts?)`    | result      | yes                    | `{ success, value }` \\| error  |\n| `$validate(data, opts?)`     | throws      | no                     | the _same_ value, narrowed     |\n| `$safeValidate(data, opts?)` | result      | no                     | `{ success, value }` \\| error  |\n| `$assert(data, opts?)`       | throws      | no                     | `void`, narrows via `asserts`  |\n| `$check(data, opts?)`        | throws      | no                     | `void`, no narrowing           |\n| `$cast(data, opts?)`         | throws      | no                     | same as `$validate`            |\n| `$matches(data, opts?)`      | `false`     | no                     | type-guard boolean             |\n| `$ifMatches(data, opts?)`    | `undefined` | no                     | the value, or `undefined`      |\n| `$isTypeOf(value)`           | `false`     | no — **no validation** | type-guard on `$type` only     |\n| `$build(data)`               | n/a         | adds `$type` only      | typed literal, **unvalidated** |\n\n### Parse vs. validate\n\nThe distinction is whether the schema may hand back a _different_ value.\n`$parse` runs in parse mode: `withDefault` defaults are filled in, `l.bytes()`\nnormalizes any typed-array view to `Uint8Array`, a `TokenSchema` instance is\naccepted in place of its string, and objects/arrays/params are copied on write\nwhen a child transforms. `$validate` runs the same checks but treats any\ntransformation as a failure, so a success guarantees the input already\nconformed as-is.\n\nThat guarantee is why validate mode is the right choice on the read path: the\nAppView hydrates stored records with `$matches` / `$safeValidate` rather than\n`$parse`, because a value with defaults applied would no longer hash to the CID\nit was stored under.\n\nParse mode does **not** decode the Lex JSON encoding — `{ \"$link\": … }` stays a\nplain object and fails CID validation. Convert first with `jsonToLex` /\n`lexParse` from `@atproto/lex-json` (see [lex-data](../lex-data/SKILL.md)).\n\n### Result shape\n\nOn failure the result _is_ a `LexValidationError` (the class implements the\nfailure shape), so `result.reason` is the error itself and `result.issues` is\nthe issue list. There is no `.error` payload object — `error` is the XRPC error\ncode string (`'InvalidRequest'`).\n\n```ts\nconst result = app.bsky.feed.post.$safeParse(unknownData)\nif (result.success) {\n  result.value // app.bsky.feed.post.Main\n} else {\n  result.reason // LexValidationError (=== result)\n  result.issues // readonly Issue[], each carrying a path\n}\n```\n\n### `$assert` vs `$check`\n\n`$assert` narrows via an `asserts` signature, which TypeScript only permits when\nthe call target has an explicit type annotation. In generic code that yields:\n\n> Assertions require every name in the call target to be declared with an\n> explicit type annotation. `ts(2775)`\n\n`$check` is the same runtime check without the narrowing — reach for it only to\nresolve that error.\n\n```ts\ndeclare const schema: RecordSchema | ObjectSchema\nschema.$check(data) // void, no ts(2775)\n```\n\n### `$isTypeOf` — discriminate, don't validate\n\nOnly compares `$type`, so it is cheap enough for hot `.find` / `.filter` loops\nover already-validated unions:\n\n```ts\nconst pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nMind the asymmetry: on a **record** schema it demands an exact `$type` match,\nbut on a **typed object** (def) schema a _missing_ `$type` also passes, since\n`$type` is optional on nested defs. On unvalidated data it proves nothing about\nthe rest of the shape.\n\n### `$build` — construct with `$type`, no validation\n\nRecords require `$type`, so hand-building one means repeating the NSID.\n`$build` sets it and types the result; it applies no defaults and runs no\nchecks, so follow with `$parse` when the input is untrusted.\n\n```ts\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello, world!',\n  createdAt: l.currentDatetimeString(),\n})\n```\n\nDefs whose `$type` is optional need no runtime call — annotate instead. Use\n`Un$Typed<T>` when a helper returns a def's fields and the caller adds `$type`:\n\n```ts\nconst view: app.bsky.feed.defs.PostView = {/* ... */}\nfunction toPrefs(): Un$Typed<app.bsky.notification.defs.Preferences> {\n  /* ... */\n}\n```\n\n### Record keys\n\nA record schema carries its key schema, for validating an rkey independently of\nthe record body:\n\n```ts\nconst result = app.bsky.feed.post.keySchema.safeValidate(rkey)\n```\n\n## Strict mode\n\nEvery validating method takes `{ strict?: boolean, path?: PropertyKey[] }`,\nstrict defaulting to `true`. Strict means \"conforms to the AT Protocol spec\";\nlenient exists because data already on the network predates parts of it.\n\n| Concern         | `strict: true` (default)                  | `strict: false`                             |\n| --------------- | ----------------------------------------- | ------------------------------------------- |\n| `datetime`      | timezone required                         | any ISO-ish datetime                        |\n| `at-uri`        | record-key component validated            | record key not validated                    |\n| `language`      | RFC 5646 §4.1 semantics enforced          | well-formed BCP 47 syntax only              |\n| blob constraint | `accept` MIME list and `maxSize` enforced | not enforced                                |\n| blob ref CID    | strict CID check                          | any valid CID                               |\n| legacy blob ref | `{ cid, mimeType }` rejected              | accepted, including the sentinel `size: -1` |\n\n```ts\nschema.$safeParse(data, { strict: false })\n```\n\n`Client`'s `strictResponseProcessing: false` threads this through every\nresponse — prefer it over sprinkling per-call options. See\n[lex-client](../lex-client/SKILL.md).\n\n## Tokens\n\nA Lexicon `token` def exports its schema in camelCase and its string value in\nPascalCase. The PascalCase export shares its name with the string literal type:\n\n```ts\napp.bsky.graph.defs.curatelist // TokenSchema\napp.bsky.graph.defs.Curatelist // 'app.bsky.graph.defs#curatelist'\ntype Curatelist = app.bsky.graph.defs.Curatelist\n```\n\n## Declaring schemas with `l`\n\nGenerated schemas cover anything backed by a Lexicon document. Use the `l`\nbuilder for one-off internal shapes, or when authoring a schema before its\nLexicon JSON exists — otherwise prefer `./lexicons/`, which stays in sync with\nthe contract.\n\n```ts\nimport { l } from '@atproto/lex'\n\nconst thing = l.typedObject(\n  'com.example.thing', // NSID\n  'main', // definition name\n  l.object({\n    name: l.string({ maxGraphemes: 64 }),\n    count: l.optional(l.withDefault(l.integer({ minimum: 0 }), 0)),\n  }),\n)\n\ntype Thing = l.Infer<typeof thing> // input type; l.InferOutput for post-parse\n```\n\nNote the shape: document builders take `(nsid, name, validator)` — a validator,\nnot a bare property map. `l.object()` is what turns a property map into one.\n\n- Primitives — `l.string()`, `l.integer()`, `l.boolean()`, `l.bytes()`,\n  `l.cid()`, `l.blob()`, `l.null()`, `l.never()`, `l.unknown()`, `l.lexMap()`,\n  `l.lexValue()`\n- Composites — `l.object()`, `l.array()`, `l.dict()`, `l.union()`,\n  `l.discriminatedUnion()`, `l.intersection()`, `l.ref()`, `l.literal()`,\n  `l.enum()`, `l.regexp()`, `l.refine()`, `l.custom()`\n- Modifiers — `l.optional()`, `l.nullable()`, `l.withDefault()`\n- Lexicon documents — `l.record()`, `l.typedObject()`, `l.typedRef()`,\n  `l.typedUnion()`, `l.token()`, `l.query()`, `l.procedure()`,\n  `l.subscription()`, `l.params()`, `l.payload()`, `l.jsonPayload()`\n\n`l` also carries the schema classes (`RecordSchema`, `ObjectSchema`, …), the\ninference helpers (`l.Infer`, `l.InferInput`, `l.InferOutput`), the branded\nstring types and format guards, and the datetime helpers\n(`l.currentDatetimeString`, `l.toDatetimeString`, `l.asDatetimeString`,\n`l.isDatetimeString`, `l.ifDatetimeString`).\n\n`@atproto/lex` re-exports all of it flat as well, but importing `l` avoids\ncolliding with globals and generic names (`string`, `object`, `record`).\n\n`ParamsSchema` additionally exposes `fromURLSearchParams()` (coercing each\nstring to the declared scalar type) and `toURLSearchParams()` — use these\ninstead of hand-rolled query-string handling.\n\n## Standard Schema\n\nEvery schema implements [Standard Schema](https://standardschema.dev/) v1 via\n`~standard`, so form libraries and validators speaking the spec accept a\nLexicon schema directly:\n\n```ts\nconst result = app.bsky.feed.post['~standard'].validate(data)\nif (!result.issues) result.value\n```\n\nThe adapter always runs in **parse mode**, so defaults and coercion apply even\nwhere a caller might expect a pure check.\n\n## Related skills\n\n[lex-setup](../lex-setup/SKILL.md) generates this tree;\n[lex-data](../lex-data/SKILL.md) covers the values these schemas validate\n(CIDs, bytes, blobs, JSON/CBOR, branded strings);\n[lex-client](../lex-client/SKILL.md) and\n[xrpc-server](../xrpc-server/SKILL.md) cover passing schemas to calls and\nroutes.\n","frontmatter":{"name":"lex-schema","description":"Validate, parse, construct, and type AT Protocol data against Lexicon schemas with `@atproto/lex` / `@atproto/lex-schema`. Use whenever code reaches into a generated `./lexicons/` tree — `$parse`, `$safeParse`, `$validate`, `$matches`, `$assert`, `$build`, `$isTypeOf`, `$type`, `$nsid`, `$lxm`, `$Params`, `$Input`, `$Output`, `$Message`, `.Main` — when checking whether an unknown record or XRPC payload conforms, when loosening checks with `strict: false`, when declaring a schema by hand with the `l` builder, when handing a Lexicon schema to a Standard Schema consumer, or when replacing Zod/Ajv-style validation with Lexicon validation. Not for codegen setup (see lex-setup) nor for the value types themselves — CIDs, bytes, blobs, JSON/CBOR encoding, branded strings (see lex-data).\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":3213,"sizeBytes":13164},{"name":"SKILL.md","path":".agents/skills/lex-setup/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lex-setup/SKILL.md","title":"lex-setup","category":"anthropic-skill","format":"markdown","content":"---\nname: lex-setup\ndescription: >\n  Wire up, maintain, or troubleshoot `@atproto/lex` code generation and the\n  per-package `lexicons.json` manifest. Use when a package needs generated\n  Lexicon schemas; when adding or editing a `lexicons.json`, its `resolvers`\n  (`directory` / `http` / `repo`) or a `lexicons-overrides` directory; when\n  migrating a package off compiling the repo-root `lexicons` directory; when\n  adding a `codegen:lex` / `lex:install` / `prebuild` script; when `./src/lexicons/`\n  or `./lexicons/` is missing, stale, or gitignored wrong; after editing anything\n  under `lexicons/`; when adding an NSID; when `pnpm codegen`, `lex build`, or\n  `lex install` fails; when `lex` resolves to the wrong binary; when a\n  post-lexicon-edit build reports \"type X is not assignable to Y\"; or when ripping\n  out legacy `@atproto/lex-cli` codegen. Reach for it before hand-editing anything\n  under a `lexicons/`, `lexicons-overrides/`, or `src/lexicons/` directory, or any\n  `lexicons.json`.\ndisable-model-invocation: false\n---\n\n# `@atproto/lex` codegen setup\n\nTwo commands do everything:\n\n- **`lex build`** — compiles Lexicon JSON into a TypeScript schema tree. Every\n  package that uses `@atproto/lex` runs this.\n- **`lex install`** — resolves the Lexicons a package's `lexicons.json` lists\n  into `./lexicons/`, recording each one's source + CID, from the manifest's\n  `resolvers` and the network.\n\nBoth ship in the `lex` bin of `@atproto/lex`. Add `@atproto/lex` as a\ndependency — it re-exports `@atproto/lex-client`, `-schema`, `-data`, and\n`-json`, so one dependency covers the common case. `@atproto/lex-cbor` is\n_not_ re-exported; depend on it directly if the package encodes/decodes CBOR.\n\n## Wiring a package in this monorepo\n\n[lexicons/](../../../lexicons/) at the repo root is the canonical source for the\n`com.atproto` and `tools.ozone` namespaces **only**. Every package manages its\nLexicons through its own committed `lexicons.json` manifest and resolves them\nwith `lex install` — no package compiles the root directory directly. The\nmanifest's `resolvers` decide where each NSID comes from; see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson).\n\nCopy the shape consuming packages use\n([packages/pds](../../../packages/pds/package.json),\n[packages/sync](../../../packages/sync/package.json),\n[packages/bsky](../../../packages/bsky/package.json)):\n\n```json\n{\n  \"scripts\": {\n    // Generate TypeScript schema from Lexicon JSON\n    \"lex:build\": \"lex build --clear --indexFile\",\n    // Install Lexicon JSON into ./lexicons/ and update the manifest. Use `pnpm lex:install foo.bar.baz` to install new lexicons\n    \"lex:install\": \"lex install\",\n    // Keep the TypeScript schema up to date after installing new lexicons\n    \"postlex:install\": \"pnpm run lex:build\",\n    // Allows to keep the manifest.json up-to-date when a canonical lexicon is updated. Also allows to fetch new lexicons from the network.\n    \"lex:update\": \"lex update\",\n    // Run the build step after updating the lexicons\n    \"postlex:update\": \"pnpm run lex:build\",\n    // When performing a regular build (eg. from CI), ensure that the lexicons are installed and match the manifest.\n    \"codegen:lex\": \"pnpm run lex:install --ci\",\n    \"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n    \"build\": \"tsc --build tsconfig.build.json\"\n  }\n}\n```\n\nThen gitignore the build artifacts. The generated TypeScript (`src/lexicons`) is\nnever committed, and the canonical schemas symlinked into `./lexicons` from the\nrepo root (`com.atproto` / `tools.ozone`) are gitignored too — they can't drift\nand are recreated on every install. Everything else resolved into `./lexicons` is\n**committed**: the `at://` network copies and the `app.bsky.*` / `chat.bsky.*`\ndocuments fetched from the bsky GitHub repo, so builds and CI work offline (the\nnetwork can't serve old record versions). The shared repo-root\n[lexicons-overrides/](../../../lexicons-overrides/) is committed too. See\n[packages/bsky/.gitignore](../../../packages/bsky/.gitignore) for the shape:\n\n```\n# @atproto/lex\n/src/lexicons\n# Canonical schemas are symlinked from the repo-wide source; everything else\n# under ./lexicons is a committed copy kept for offline builds and CI.\n/lexicons/com/atproto\n/lexicons/tools/ozone\n```\n\nA package whose Lexicons are _all_ symlinked canonical (e.g.\n[packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json))\nhas nothing to commit under `./lexicons` and can gitignore the whole `/lexicons`\ntree instead.\n\nThe indirection through `codegen:lex` is deliberate:\n\n- `prebuild` runs every `codegen:*` script the package declares, so a package\n  that also generates protobuf or templates needs no change to this line.\n  `enable-pre-post-scripts = true` in `.npmrc` is what makes a build fire\n  `prebuild` at all.\n- Root `pnpm codegen` runs `build:tooling` and then every `codegen:*` script\n  across the workspace in parallel. A generator hidden inside `prebuild` would\n  be skipped by it.\n\n### Which `lex` binary you get\n\n`@atproto/lex-cli` (legacy) and `@atproto/lex` both claim the `lex` bin name,\nand resolution is per-package. In `packages/api` — which still depends on\n`lex-cli` for `gen-api` — `pnpm exec lex` gives you the **legacy** CLI, not\n`lex build`. `api` therefore installs its manifest with the `lex-installer`\nbin (`@atproto/lex-installer`) and feeds the installed `./lexicons` to\n`gen-api`. Everywhere else `lex` is `@atproto/lex`.\n\n`gen-api` is slow, so `api`'s `lex:gen` is mtime-guarded: it only re-runs when\n`./src/client/index.ts` is missing or some lexicon content is newer than it.\nUse `find -L ./lexicons -type f` (not the symlinks themselves) — `lex install`\nre-creates the canonical symlinks and rewrites `lexicons.json` on every run, so\ntheir mtimes are meaningless; `-L` follows each symlink to its (stable) target,\nand the regular files are the committed/fetched copies.\n\nIf a `lex build` invocation errors about an unknown command, that collision is\nwhy. `@atproto/lex` also registers `ts-lex`, which is unambiguous. Outside a\nworkspace, `npx @atproto/lex` avoids pulling the unrelated unscoped `lex`\npackage that bare `npx lex` would fetch.\n\n### Manifest resolvers (`lexicons.json`)\n\nThe manifest's `lexicons` array lists the NSID **entry points** the package\nneeds; `lex install` pulls in schemas they reference transitively, so you only\nlist the roots. `resolvers` is an **ordered** list deciding where each NSID is\nfetched from — earlier entries win, with the AT Protocol network as the implicit\nlast fallback. The kinds:\n\n- `{ \"type\": \"directory\", \"path\": \"../../lexicons\", \"include\": [\"com.atproto.*\", \"tools.ozone.*\"] }`\n  — resolve from a local directory. The canonical set lives at the repo root;\n  matches are **symlinked** into `./lexicons` and locked with a `file://` URI, so\n  they can't drift. Adjust `path` for the package's depth.\n- `{ \"type\": \"http\", \"base\": \"https://raw.githubusercontent.com/bluesky-social/bsky/refs/heads/main/lexicons/\", \"include\": [\"app.bsky.*\", \"chat.bsky.*\"] }`\n  — fetch `<base>/<…nsid.segments>.json` (e.g. a raw Git host). `http` falls\n  through on 404 but throws on other errors.\n- `{ \"type\": \"repo\", \"repo\": \"<handle|did>\" }` — resolve each NSID from that\n  repo's `com.atproto.lexicon.schema` records; never falls through.\n- A `directory` resolver pointing at the shared, committed repo-root\n  **`../../lexicons-overrides`** directory, listed **after** the canonical\n  resolver but **before** the `http`/network ones, so it overrides the lexicons\n  they would otherwise return (it cannot override canonical, which resolves\n  first). Put any document that must differ here: unpublished NSIDs (`internal.*`,\n  `place.stream.badge.defs`, a not-yet-published `app.bsky.unspecced.*`),\n  documents whose local version is ahead of the published one, and minimal\n  compatibility relaxations of published third-party lexicons that network\n  records violate (see\n  [Compatibility overrides](../../../lexicons-overrides/README.md#compatibility-overrides);\n  the PDS must `exclude` them so writes keep validating against the published\n  schema). All packages share\n  the one [lexicons-overrides/](../../../lexicons-overrides/) at the repo root.\n\n[packages/bsky/lexicons.json](../../../packages/bsky/lexicons.json) is the full\nreference (overrides + canonical directory + GitHub `http`).\n\n`include`/`exclude` globs accept `*` as a wildcard; `exclude` is applied after\n`include`.\n\n### Packages under `packages/lex/`\n\n`@atproto/lex` depends on the SDK sub-packages, so they cannot depend back on it\n(it would be circular). They generate with `@atproto/lex-builder`'s `lex-builder\nbuild --lib @atproto/lex-schema` (that is where the generated files import `l`\nfrom) instead of `lex build`, and install their manifest with `@atproto/lex-installer`'s\n`lex-installer install` instead of `lex install`:\n\n```jsonc\n\"lex:build\": \"lex-builder build --out ./src/lexicons --clear --lib @atproto/lex-schema --index-file\",\n\"lex:install\": \"lex-installer install\",\n\"postlex:install\": \"pnpm run lex:build\",\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n```\n\nSee [packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json).\nAdd `--default-export` to `lex:build` for a browser-facing client\n([packages/lex/lex-client](../../../packages/lex/lex-client/package.json)).\n\n## `lex build` flags\n\nDefaults: `--lexicons ./lexicons`, `--out ./src/lexicons`, `--no-pretty`,\n`--default-export`. yargs accepts either kebab-case or camelCase\n(`--index-file` == `--indexFile`).\n\n- `--clear` — wipe the output dir first, so deleted lexicons don't linger\n- `--override` — overwrite existing files (no-op alongside `--clear`)\n- `--indexFile` — emit `index.ts` re-exporting root namespaces (`app`, `com`, …)\n- `--include` / `--exclude <patterns…>` — filter by NSID\n- `--lib <package>` — where generated files import `l` from\n- `--importExt <ext>` — import specifier extension (`.js`; `\"\"` for extensionless)\n- `--fileExt <ext>` — emitted file extension (`.ts`)\n- `--no-pretty` — skip prettier\n- `--no-defaultExport` — drop `default` re-exports (see [Tree-shaking](#tree-shaking))\n- `--defsExport` — also expose defs under `$defs`, for when a child namespace\n  shadows a sibling definition (`com.example.foo` vs `com.example.foo.bar`)\n- `--ignore-errors` / `--ignore-invalid-lexicons` — skip bad inputs instead of failing\n\n## Using the generated tree\n\n`--indexFile` gives one entry point for all root namespaces; schemas are\naddressed by NSID dot-path:\n\n```ts\nimport { app, com, chat } from '../lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def\ncom.atproto.repo.getRecord // query/procedure schema\n```\n\nEach NSID compiles to a `<name>.defs.ts` holding the schema plus a thin\n`<name>.ts` re-exporting it (and `main` as `default`). Nothing under\n`src/lexicons/` is hand-editable — it is deleted and rewritten on every\n`--clear` build.\n\n### Tree-shaking\n\nThe generated tree is tree-shakeable, but the reference style decides how much\nsurvives. A bundler can't tell that `client.call()` only consumes `.main`, so\nnaming the namespace retains every sibling def inside it:\n\n```ts\n// Smallest — default import; preferred for browser bundles\nimport getRecord from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(getRecord, {})\n\n// Same size, but leaks the `main` identifier into your source\nimport { main as getRecord } from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(com.atproto.repo.getRecord.main, {})\n\n// Largest — namespace notation drags in sibling defs\nawait client.call(com.atproto.repo.getRecord, {})\n```\n\nServices, scripts, and tests here use namespace notation, which reads closest\nto the NSID and costs nothing server-side. Libraries and browser apps should\nprefer the default import.\n\n## After editing lexicon JSON\n\nGenerated TypeScript is not rebuilt automatically. Regenerate before building\nor testing anything downstream — a stale tree is by far the most common cause\nof \"type X is not assignable to Y\" right after a lexicon edit:\n\n```bash\npnpm codegen        # repo root: every codegen:* script in the workspace\npnpm run prebuild   # inside one package\n```\n\n`pnpm codegen` builds `@atproto/lex-builder` first via `build:tooling`. If\ncodegen output still looks stale, that build is the thing to re-run.\n\nAdding a new external NSID means listing it in the package's `lexicons.json`\n`lexicons` array (or `pnpm lex:install <nsid>`), then re-running codegen. A new\ncanonical (`com.atproto.*` / `tools.ozone.*`) schema is added at the repo root\nand picked up by each dependent package's next `lex:install`; run\n`pnpm lex:update-local` from the root to propagate a changed canonical CID into\nevery manifest.\n\n## Network-installed schemas (outside this repo)\n\nFor a package whose Lexicons are _not_ maintained alongside it, `lex install`\nfetches them from the network into `./lexicons/` and records each resolution\n(AT URI + CID) in `./lexicons.json`.\n\n```bash\nlex install app.bsky.feed.post   # add NSIDs; dependencies resolve automatically\nlex install                      # install everything the manifest lists\nlex install --update             # re-resolve all to latest\nlex install --ci                 # fail if installed files drift from manifest CIDs\nlex install --no-save <nsid>     # install without touching the manifest\n```\n\nFlags: `--manifest <path>` (default `./lexicons.json`), `--lexicons <dir>`\n(default `./lexicons`), `--update`, `--ci`, `--no-save`.\n\nCommit both the manifest and the fetched JSON — they are the input contract,\nand the recorded CIDs are what makes `--ci` meaningful. The upstream-recommended\nwiring adds `\"postinstall\": \"lex install --ci\"` to catch drift on every install\nand `\"update-lexicons\": \"lex install --update\"` as the deliberate refresh.\n\nDon't commit a second **copy** of schemas that already live in\n[lexicons/](../../../lexicons/) — it would silently diverge. In-repo, vendor by\n**symlink** instead (next section), which can't drift.\n\n## Vendoring the canonical lexicons (in-repo)\n\nConsuming [lexicons/](../../../lexicons/) through `lex install` (rather than\ncompiling it directly) is the standard setup — it makes a package's lexicon set\nan explicit, CID-locked contract. A `directory` resolver resolves each canonical\nNSID from the root; those resolutions are **symlinked** into `./lexicons/` (not\ncopied) and locked with a `file://` URI relative to the manifest, so they can't\ndiverge. [packages/pds](../../../packages/pds/package.json) is the reference:\n\n```jsonc\n// lexicons.json (committed) — roots + resolvers + CID locks\n{\n  \"version\": 2,\n  \"lexicons\": [\"com.atproto.repo.getRecord\", \"…every vendored NSID…\"],\n  \"resolvers\": [\n    {\n      \"type\": \"directory\",\n      \"path\": \"../../lexicons\",\n      \"include\": [\"com.atproto.*\", \"tools.ozone.*\"],\n    },\n  ],\n  \"resolutions\": {\n    \"com.atproto.repo.getRecord\": {\n      \"uri\": \"file://../../lexicons/com/atproto/repo/getRecord.json\",\n      \"cid\": \"…\",\n    },\n  },\n}\n```\n\n```jsonc\n// package.json — install (creating symlinks + verifying the lock), then build\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n\"lex:install\": \"lex install\",     // regenerate the manifest after adding/removing a lexicon\n\"postlex:install\": \"pnpm run lex:build\",\n\"lex:build\": \"lex build --clear --indexFile\",\n```\n\nGitignore the symlinked canonical dirs under `/lexicons` (`com/atproto`,\n`tools/ozone` — build artifacts) but **commit `lexicons.json`** (the contract),\nalong with any network- or GitHub-resolved copies. Generate/refresh the manifest with\n`pnpm run lex:install`; `--ci` then fails the build if `/lexicons` drifts from\nthe locked CIDs. Tradeoff: the `lexicons` roots list is explicit, so adding a\nnew canonical schema requires re-running `lex:install` to include it — unlike a\nplain `lex build --lexicons ../../lexicons`, which picks up every file\nimplicitly. For the full resolver vocabulary (`directory` / `http` / `repo`,\nordering, and the `lexicons-overrides` directory) see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson) above.\n\n## Removing legacy `@atproto/lex-cli` codegen\n\nThe legacy output directory depends on which generator ran — `lex gen-server`\nemits `./src/lexicon` (singular), `lex gen-api` emits `./src/client`. Both are\nfully generated and safe to delete. Leave `lexicons/` alone; it is input, not\noutput.\n\nThen drop `@atproto/lex-cli` from `devDependencies`, add a `lexicons.json`\n(resolving the canonical set from the root plus whatever external namespaces the\npackage used), replace the old `codegen:lex` with the `lex:install` / `lex:build`\nform above, and repoint `.gitignore` at `src/lexicons` plus the symlinked\ncanonical dirs under `/lexicons`. (`packages/api` is the exception — it keeps `gen-api` but now\nfeeds it a locally-installed `./lexicons`; see\n[Which `lex` binary you get](#which-lex-binary-you-get).)\n\n## Related skills\n\nOnce the package generates code: [lex-schema](../lex-schema/SKILL.md)\n(`$`-accessors, validation), [lex-data](../lex-data/SKILL.md) (values, blobs,\nCBOR, branded strings), [lex-client](../lex-client/SKILL.md) (calling out),\n[xrpc-server](../xrpc-server/SKILL.md) (defining routes), and\n[lexification-client](../lexification-client/SKILL.md) /\n[lexification-server](../lexification-server/SKILL.md) (migrating off the\nlegacy stack).\n","frontmatter":{"name":"lex-setup","description":"Wire up, maintain, or troubleshoot `@atproto/lex` code generation and the per-package `lexicons.json` manifest. Use when a package needs generated Lexicon schemas; when adding or editing a `lexicons.json`, its `resolvers` (`directory` / `http` / `repo`) or a `lexicons-overrides` directory; when migrating a package off compiling the repo-root `lexicons` directory; when adding a `codegen:lex` / `lex:install` / `prebuild` script; when `./src/lexicons/` or `./lexicons/` is missing, stale, or gitignored wrong; after editing anything under `lexicons/`; when adding an NSID; when `pnpm codegen`, `lex build`, or `lex install` fails; when `lex` resolves to the wrong binary; when a post-lexicon-edit build reports \"type X is not assignable to Y\"; or when ripping out legacy `@atproto/lex-cli` codegen. Reach for it before hand-editing anything under a `lexicons/`, `lexicons-overrides/`, or `src/lexicons/` directory, or any `lexicons.json`.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":4561,"sizeBytes":17407},{"name":"SKILL.md","path":".agents/skills/lexification-client/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lexification-client/SKILL.md","title":"lexification-client","category":"anthropic-skill","format":"markdown","content":"---\nname: lexification-client\ndescription: >\n  Migrate AT Protocol client and consumer code off the legacy `@atproto/api`,\n  `@atproto/lexicon`, and `@atproto/xrpc` stack onto `@atproto/lex`. Use\n  whenever code that *calls out* to an XRPC service is being modernized —\n  replacing an `AtpAgent` with `Client`, `result.data` / `result.headers[…]`\n  response handling, `XRPCError` try/catch, `jsonStringToLex` / `stringifyLex`,\n  `BlobRef` `instanceof` checks, `CID` from `multiformats`,\n  `new Date().toISOString()` on AT Proto datetimes, bare `string` DIDs and\n  handles, or legacy `isX()` type guards — including when the request is\n  phrased as \"clean this up\" or \"modernize this file\" rather than \"migrate\".\n  For code that *defines* routes, use the lexification-server skill.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating client / consumer code\n\nScope: **calls out**. Anything that used `AtpAgent` or the `@atproto/lexicon` /\n`@atproto/xrpc` runtime to talk to an AT Proto service, plus the data types\nthat flow across that boundary.\n\nRoute _definitions_ (`server.add`, handler signatures, codegen removal) are the\n[lexification-server skill](../lexification-server/SKILL.md). A service package\nalmost always needs both: it defines routes **and** calls other services. If\nyou're mid-migration on a service and reach an `agent.api.…` call, you're in\nthe right file.\n\n## What changes\n\n| Old                                               | New                                                                    |\n| ------------------------------------------------- | ---------------------------------------------------------------------- |\n| `AtpAgent` from `@atproto/api`                    | `Client` from `@atproto/lex`                                           |\n| `agent.api.<ns>.<method>(params, { headers })`    | `client.call(<schema>, params, opts)` or `client.xrpc(<schema>, opts)` |\n| `result.data`                                     | `result.body`                                                          |\n| `result.headers['content-language']`              | `result.headers.get('content-language')` (standard `Headers`)          |\n| `XRPCError` from `@atproto/xrpc`                  | `XrpcError` from `@atproto/lex`                                        |\n| try/catch + `instanceof XRPCError`                | `xrpcSafe()` / `client.xrpcSafe()` discriminated result                |\n| `HeadersMap` from `@atproto/xrpc`                 | `Headers as HeadersMap` from `@atproto/xrpc-server`                    |\n| `jsonStringToLex` / `stringifyLex`                | `lexParse` / `lexStringify`                                            |\n| `BlobRef` class from `@atproto/lexicon`           | `BlobRef` interface from `@atproto/lex-data` (or `@atproto/lex`)       |\n| `CID` from `multiformats/cid`                     | `Cid` / `parseCid` from `@atproto/lex`                                 |\n| `new Date().toISOString()` for AT Proto datetimes | `currentDatetimeString()` / `toDatetimeString()`                       |\n| `isX(v)` from `src/lexicon/types/…`               | `<ns>.<def>.$matches(v)` / `.$isTypeOf(v)`                             |\n\n## XRPC calls\n\nThree shapes, in rough order of preference — pick the least machinery that\ncovers what the call site actually uses.\n\n### `call()` — you only want the body\n\nErrors propagate as exceptions. Params (query) or body (procedure) go in the\nsecond argument; everything else in the third.\n\n```ts\nreturn ctx.suggestionsClient.call(\n  app.bsky.unspecced.getSuggestedUsersSkeleton,\n  { limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined },\n  { headers: params.headers },\n)\n```\n\n### `xrpc()` — you need status or headers too\n\n```diff\n- const res = await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton(\n-   { viewer: params.hydrateCtx.viewer, relativeToDid },\n-   { headers: params.headers },\n- )\n- return {\n-   suggestedDids: res.data.actors.map((a) => a.did),\n-   resHeaders: res.headers,\n- }\n+ const res = await ctx.suggestionsClient.xrpc(\n+   app.bsky.unspecced.getSuggestionsSkeleton,\n+   {\n+     params: { viewer: params.hydrateCtx.viewer, relativeToDid },\n+     headers: params.headers,\n+   },\n+ )\n+ const contentLang = res.headers.get('content-language')\n+ return {\n+   suggestedDids: res.body.actors.map((a) => a.did),\n+   resHeaders: contentLang ? { 'content-language': contentLang } : undefined,\n+ }\n```\n\nEverything is one options object: query params under `params`, procedure\nbodies under `body` (not `input`), plus `headers`, `signal`, `service`,\n`labelers`, `maxRetries`, and the validation flags.\n\nResponse headers are a standard `Headers` instance. If the value flows into\nsomething typed `HeadersMap` (`Record<string, string>`), pull the specific\nheaders out with `.get()` as above; if the consumer takes `Headers`, pass it\nthrough unchanged.\n\n### `xrpcSafe()` — errors are part of the contract\n\nReturns a discriminated result instead of throwing, and the failure is typed\nagainst the method schema. Use it where the call site already had a `catch`\nthat inspected the error rather than just rethrowing.\n\nNarrow on the concrete failure class when the branches map to different\ndownstream responses (see `packages/bsky/src/api/app/bsky/feed/getFeed.ts`):\n\n```ts\nimport {\n  XrpcInvalidResponseError,\n  XrpcResponseError,\n  xrpcSafe,\n} from '@atproto/lex'\n\nconst result = await xrpcSafe(fgEndpoint, app.bsky.feed.getFeedSkeleton, {\n  strictResponseProcessing: false,\n  signal: AbortSignal.timeout(10_000),\n  headers,\n  params: { feed, limit, cursor },\n})\n\nif (!result.success) {\n  const cause = result.reason\n\n  // Structurally valid XRPC error (4xx/5xx) — pass it through\n  if (cause instanceof XrpcResponseError) {\n    const { status, body } = cause.toDownstreamError()\n    throw new XRPCError(status, body.message, body.error, { cause })\n  }\n\n  // Response didn't match the schema\n  if (cause instanceof XrpcInvalidResponseError) {\n    throw new UpstreamFailureError(\n      'feed provided an invalid response',\n      'InvalidFeedResponse',\n      { cause },\n    )\n  }\n\n  // Typically a network error\n  throw new UpstreamFailureError('feed unavailable', undefined, { cause })\n}\n\nconst { feed: feedSkele, cursor } = result.body // typed\n```\n\n`toDownstreamError()` is what replaces hand-mapping upstream status codes: it\nremaps 500 → 502 and strips hop-by-hop headers. It does **not** redact — when\nthe upstream payload is a structurally valid XRPC error, its `error` / `message`\nare passed through verbatim for transparency. Sanitize them yourself before\nre-throwing if the upstream may leak internal detail.\n\nWhen you only care about one schema-declared error code, skip the class checks:\n\n```ts\nconst res = await xrpcSafe(pds, com.atproto.sync.getLatestCommit, {\n  params: { did },\n})\nif (res.success) return true\nif (res.error === 'RepoNotFound') return false\nthrow res.reason\n```\n\n`result.matchesSchemaErrors()` narrows `result.error` to the codes the method\ndeclares, for when you want the compiler to check the code strings.\n\nThe old `ResponseType` enum comparisons have no replacement and don't need one:\n`XrpcInvalidResponseError` covers `ResponseType.InvalidResponse`, and\n`XrpcInternalError` / `XrpcFetchError` cover `ResponseType.Unknown`.\n\nRetries are built in (`maxRetries`, default `0`), so delete any hand-rolled\nretry loop wrapped around the call. See [lex-client](../lex-client/SKILL.md)\nfor the full error hierarchy and client API.\n\n## Agent → Client\n\n```diff\n- import { AtpAgent } from '@atproto/api'\n+ import { Client } from '@atproto/lex'\n\n- searchAgent: AtpAgent | undefined\n+ searchClient: Client | undefined\n```\n\n`Client` takes **two** arguments: the agent (a service URL, an `AgentConfig`,\nor a session), then per-client options. Static headers belong in the agent\nconfig rather than an imperative `setHeader` after construction — one frozen\nconfig then describes the whole client (see `packages/bsky/src/index.ts`):\n\n```diff\n- const myServiceAgent = config.serviceUrl\n-   ? new AtpAgent({ service: config.serviceUrl })\n-   : undefined\n- if (myServiceAgent && config.serviceApiKey) {\n-   myServiceAgent.api.setHeader('authorization', `Bearer ${config.serviceApiKey}`)\n- }\n+ const myServiceClient = config.serviceUrl\n+   ? new Client(\n+       {\n+         service: config.serviceUrl,\n+         headers: config.serviceApiKey\n+           ? { authorization: `Bearer ${config.serviceApiKey}` }\n+           : undefined,\n+       },\n+       {\n+         // Trust internal services to send us well-formed responses\n+         strictResponseProcessing: false,\n+         validateResponse: config.debugMode,\n+       },\n+     )\n+   : undefined\n```\n\nThat second argument is worth setting for internal service-to-service clients:\n`Client` validates responses by default, which is stricter than `AtpAgent` was,\nso a migrated call can start failing on data the old code happily accepted.\n\n## Errors\n\n```diff\n- import { XRPCError } from '@atproto/xrpc'\n+ import { XrpcError } from '@atproto/lex'\n```\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for **throwing** errors out of\nhandlers — that one is unrelated and stays. A file can legitimately import\nboth (getFeed.ts above does): `XrpcError` describes a failed call out,\n`XRPCError` produces the response going back down.\n\n## Headers map type\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import type { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAlias it — the unaliased `Headers` shadows the global `Headers` type, which the\nsame file usually also needs.\n\n## Data utilities\n\n### JSON ↔ Lex\n\n```diff\n- import { jsonStringToLex } from '@atproto/lexicon'\n+ import { lexParse } from '@atproto/lex'\n\n- const parsed = jsonStringToLex(payload.toString('utf8')) as SubjectActivitySubscription\n+ const parsed = lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(\n+   payload.toString('utf8'),\n+ )\n```\n\n`lexParse` takes a type parameter, so the `as` cast goes away. `stringifyLex` →\n`lexStringify`. See [lex-data](../lex-data/SKILL.md) for `jsonToLex`,\n`lexToJson`, `lexParseJsonBytes`, `parseLexLink`, `parseLexBytes`.\n\n### Datetime strings\n\n```diff\n- createdAt: new Date().toISOString(),\n+ createdAt: currentDatetimeString(),\n\n- indexedAt: someDate.toISOString(),\n+ indexedAt: toDatetimeString(someDate),\n```\n\nBoth come from `@atproto/lex` and return the branded `DatetimeString`, so a\nvalue that reaches a lexicon field is checked at the type level rather than at\nvalidation time.\n\n### CIDs\n\n```diff\n- import { CID } from 'multiformats/cid'\n+ import { type Cid, parseCid } from '@atproto/lex'\n```\n\n`Cid` is an interface. Drop `multiformats` from the package's dependencies once\nno imports remain.\n\n### BlobRef\n\n`BlobRef` is now an interface (a union of `TypedBlobRef` and `LegacyBlobRef`),\nso `instanceof` no longer works — and the hand-written fallbacks that used to\npaper over the two JSON shapes are exactly what the helpers replace:\n\n```diff\n- import { BlobRef } from '@atproto/lexicon'\n+ import { type BlobRef, getBlobCidString } from '@atproto/lex-data'\n\n- export const cidFromBlobJson = (json: BlobRef) => {\n-   if (json instanceof BlobRef) return json.ref.toString()\n-   if (json['$type'] === 'blob') return (json['ref']?.['$link'] ?? '') as string\n-   return (json['cid'] ?? '') as string\n- }\n+ export const cidFromBlobJson = (json: BlobRef): string => getBlobCidString(json)\n```\n\n```diff\n- if (value instanceof BlobRef) { ... }\n+ if (isBlobRef(value)) { ... }\n```\n\n`isBlobRef` validates the CID strictly by default; pass `{ strict: false }` for\nlegacy data. See [lex-data](../lex-data/SKILL.md) for the\n`TypedBlobRef` / `LegacyBlobRef` split.\n\n## Branded types at boundaries\n\nApply `DidString`, `HandleString`, `AtUriString`, `DatetimeString`, `Cid` at\nsignatures, interface fields, and DB schema types — that's where they stop bad\nvalues propagating. Prefer importing them from `@atproto/lex` over\n`@atproto/syntax`.\n\n```diff\n- did: string\n+ did: DidString\n\n- iss: string\n+ iss: DidString | `${DidString}#${string}`\n```\n\nPrefix checks become guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nData arriving from protobuf, the data plane, or Kysely is plain `string`. Cast\nonce at that entry point rather than asserting at every later use:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n```\n\n## Type guards\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { ... }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { ... }\n```\n\n`$isTypeOf` is the behavioral equivalent of a legacy `isX()` guard, so it is the\ndefault when replacing one — this is what the repo migrated to (see\n`packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts`). It checks only\nthe `$type` tag, which is all that matters on an already-validated union, and it\ndoubles as a predicate:\n\n```diff\n- const pref = prefs.find((p) => p.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ const pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` instead only where the data is genuinely untrusted and a\n`$type` tag alone isn't enough of a guarantee — it validates the whole value, so\nit costs more. Treat that as a deliberate upgrade at an entry point, not the\ndefault swap for an `isX()` call.\n\nSee [lex-schema](../lex-schema/SKILL.md) for the full `$`-accessor cheat sheet.\n\n## Tests\n\nTest migration is partial and lags the source migration. The `pds`, `bsky` and\n`ozone` suites mostly still drive `AtpAgent` from `@atproto/api`, and `dev-env`\nexposes both `getAgent(): AtpAgent` and `getClient(): Client` for exactly that\nreason.\n\nA suite still on `AtpAgent` hydrates blobs into **legacy `BlobRef` class\ninstances**, which only `@atproto/lexicon`'s `lexToJson` knows how to flatten.\nSwapping a test helper to `@atproto/lex`'s `lexToJson` leaves those instances\nuntouched and leaks their internal `original` field into snapshots — which reads\nlike a wire-format regression in the service under test. Keep the legacy\nconverter until the suite itself moves to `Client`.\n\nDefault to leaving passing tests alone: during a source migration their value\nis being an unchanged runtime regression check. Two things force a change:\n\n- **A test imports a legacy path you deleted** (`src/lexicon/…`). Point it at\n  the `@atproto/api` equivalent (`ComAtprotoAdminDefs`, `ids`, `$Typed`) — the\n  smallest edit that keeps the test running.\n- **You are deliberately migrating that test file.** Then it moves wholesale:\n  `network.pds.getClient()`, schemas from `src/lexicons/index.js`, `res.body`,\n  and helpers like `getBlobCidString` from `@atproto/lex-data`.\n  `packages/pds/tests/file-uploads.test.ts` is a migrated example.\n\nAvoid half-migrating a file — a suite mixing `agent.api.…` and `client.call(…)`\nfor the same operation is harder to read than either end state.\n\n## Import source summary\n\n| Before                                            | After                                            |\n| ------------------------------------------------- | ------------------------------------------------ |\n| `@atproto/api` (`AtpAgent`)                       | `@atproto/lex` (`Client`)                        |\n| `@atproto/lexicon` (`jsonStringToLex`, `BlobRef`) | `@atproto/lex` (`lexParse`, `BlobRef`)           |\n| `@atproto/lexicon` (`stringifyLex`)               | `@atproto/lex` (`lexStringify`)                  |\n| `@atproto/xrpc` (`XRPCError`)                     | `@atproto/lex` (`XrpcError`, `xrpcSafe`)         |\n| `@atproto/xrpc` (`HeadersMap`)                    | `@atproto/xrpc-server` (`Headers as HeadersMap`) |\n| `multiformats/cid` (`CID`)                        | `@atproto/lex` (`Cid`, `parseCid`)               |\n| `@atproto/syntax` (`DidString`, etc.)             | `@atproto/lex` (prefer this)                     |\n\nDropped imports usually mean dropped `package.json` dependencies\n(`multiformats`, `@atproto/lexicon`, `@atproto/xrpc`), and every package\ntouched needs a changeset entry.\n\n## Related skills\n\n[lex-client](../lex-client/SKILL.md) for the full `Client` API you're migrating\nonto, [lex-setup](../lex-setup/SKILL.md) for codegen and dependency wiring, and\n[lexification-server](../lexification-server/SKILL.md) for the route-definition\nhalf — service packages need both.\n","frontmatter":{"name":"lexification-client","description":"Migrate AT Protocol client and consumer code off the legacy `@atproto/api`, `@atproto/lexicon`, and `@atproto/xrpc` stack onto `@atproto/lex`. Use whenever code that *calls out* to an XRPC service is being modernized — replacing an `AtpAgent` with `Client`, `result.data` / `result.headers[…]` response handling, `XRPCError` try/catch, `jsonStringToLex` / `stringifyLex`, `BlobRef` `instanceof` checks, `CID` from `multiformats`, `new Date().toISOString()` on AT Proto datetimes, bare `string` DIDs and handles, or legacy `isX()` type guards — including when the request is phrased as \"clean this up\" or \"modernize this file\" rather than \"migrate\". For code that *defines* routes, use the lexification-server skill.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":4148,"sizeBytes":16490},{"name":"project-config.md","path":".agents/skills/lexification-server/references/project-config.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lexification-server/references/project-config.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Project configuration\n\nRead this for step 1 of the migration: replacing `lex gen-server` codegen with\n`lex build`, fixing dependencies, and deleting the legacy generated tree.\n\n`packages/ozone/package.json` is the current \"before\"; `packages/pds` and\n`packages/bsky` are the \"after\".\n\n## Scripts\n\nBefore (ozone — note the hand-maintained glob list and the staleness check):\n\n```jsonc\n\"codegen:lex\": \"lex gen-server --yes ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/*\",\n\"prebuild\": \"[ -f ./src/lexicon/index.ts ] && [ -z \\\"$(find ../../lexicons -newer ./src/lexicon/index.ts -type f -print -quit)\\\" ] || pnpm run codegen:lex\",\n```\n\nAfter:\n\n```jsonc\n\"codegen:lex\": \"lex build --clear --indexFile --lexicons ../../lexicons\",\n\"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n```\n\nTwo things fall out of this, both deliberate:\n\n- No NSID list. `lex build` compiles the whole `lexicons/` tree, so adding a\n  lexicon needs no `package.json` edit. (Small libraries use `--include` to\n  trim output; a service that serves most of a namespace should not.)\n- No staleness guard. `--clear` regenerates unconditionally and the glob\n  `prebuild` picks up every `codegen:*` script the package declares, so a\n  package that also generates protobuf (`bsky`) or templates (`pds`) needs no\n  change to that line. Keep the name `codegen:lex` — root `pnpm codegen` runs\n  `/^(codegen:.+)$/` across the workspace, and a generator hidden inside\n  `prebuild` would be skipped by it.\n\nFull flag reference: [lex-setup skill](../../lex-setup/SKILL.md).\n\n## Dependencies\n\nAdd to `dependencies`:\n\n- `@atproto/lex`\n- `@atproto/xrpc-server` (already present in every service — verify, don't add\n  blindly)\n\nRemove:\n\n- `@atproto/lexicon` — `dependencies`\n- `@atproto/xrpc` — `dependencies`\n- `multiformats` — `dependencies`; CIDs come from `@atproto/lex-data` via\n  `@atproto/lex`\n- `@atproto/lex-cli` — `devDependencies`\n\nAdd the `@atproto/lex-*` sub-packages only when a file imports them directly.\n`pds` lists `lex-cbor`, `lex-data`, and `lex-json` in `dependencies` and\n`lex-document` in `devDependencies` for exactly that reason; `bsky` needs none\nof them and imports everything through the umbrella.\n\nKeep `@atproto/api` only where it is genuinely still used. It stays a\ndevDependency of `pds` (tests) and a runtime dependency of `bsky` (two source\nfiles import `getAgeAssuranceRegionConfig` and age-assurance helpers from it).\n\nEvery package touched needs a `.changeset/` entry — `minor` for a package\nwhose public API changes, `patch` otherwise.\n\n## Gitignore\n\nGenerated output is a build artifact:\n\n```\n# @atproto/lex\nsrc/lexicons\n```\n\nReplaces the `# @atproto/lex-cli` / `src/lexicon` stanza. Note singular →\nplural: `src/lexicon` was the legacy path, `src/lexicons` is the new one. Both\nlines are unanchored patterns, not `/src/lexicons/`.\n\n## Deleting the legacy tree\n\n```sh\nrm -rf ./src/lexicon\n```\n\nSafe because it was gitignored and regenerated. Verify nothing still imports\nit before deleting — in ozone that is ~76 source files plus ~47 test files, so\nexpect the package not to compile until step 3 is done.\n\nDo **not** touch the repo-root `lexicons/` directory. It is canonical\ncommitted source shared by every package and the interop fixtures.\n\n## What does not apply in this monorepo\n\n`lex install`, `manifest.json` / `lexicons.json`, and a `postinstall` hook are\nreal `@atproto/lex` features for consuming schemas published over the network.\nNo package here uses them, because the schemas are committed at the repo root.\nDo not introduce a manifest during a migration.\n","isInternal":false,"tokens":991,"sizeBytes":3661},{"name":"routes.md","path":".agents/skills/lexification-server/references/routes.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lexification-server/references/routes.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Migrating route files\n\nRead this for step 3: converting `server.<ns>.<method>(…)` registrations to\n`server.add(schema, …)`, and moving parameter/output types off\n`../lexicon/types/…`.\n\nFor the target API in full — auth verifiers, rate limits, pipethrough, error\nclasses — see the [xrpc-server skill](../../xrpc-server/SKILL.md). This file\ncovers only what the _migration_ changes.\n\n## Registration\n\nThe namespace chain becomes a schema argument. Everything inside the config\nobject is unchanged:\n\n```diff\n- server.app.bsky.feed.getAuthorFeed({\n+ server.add(app.bsky.feed.getAuthorFeed, {\n    auth: ctx.authVerifier.optionalStandardOrRole,\n    handler: async ({ params, auth, req }) => { /* unchanged */ },\n  })\n```\n\nThe bare-handler form drops the wrapper object:\n\n```diff\n- server.com.atproto.identity.resolveHandle(async ({ req, params }) => {\n+ server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n```\n\nThe schema always comes from the generated tree, imported at the top of the\nfile alongside `Server`:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../../lexicons/index.js'\n```\n\nMigrated code overwhelmingly uses the object form (~187 registrations in\n`pds` + `bsky`, versus ~8 bare-handler). Preserve whichever form the file\nalready had; there is no reason to convert between them during a migration.\n\n## `encoding` and literal widening\n\nThe form of the route — bare handler vs `{ handler }` object — is not what\ndecides this. What matters is whether the returned literal is still\n_contextually typed_ by `server.add()`. Returning it directly from a handler\nthat declares at least one parameter is, in both forms, so `as const` is\nunnecessary. Contextual typing is lost when the response is built into an\nintermediate variable first, or when the handler declares no parameters at all\n(`async () => ({ … })`); then `encoding` widens to `string` and the overload\nfails with `TS2769: No overload matches this call`.\n\n```ts\n// contextually typed — `as const` unnecessary in either form\nserver.add(app.bsky.graph.getRelationships, {\n  handler: async ({ params }) => {\n    return { encoding: 'application/json', body: { actor, relationships } }\n  },\n})\n\n// also contextually typed; the `as const` here is redundant but harmless,\n// and is what the real resolveHandle route still carries\nserver.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n  return { encoding: 'application/json' as const, body: { did } }\n})\n```\n\nMany existing routes carry `as const` where it is not strictly needed; it is\nharmless, so leave it alone rather than churning call sites. Full rules live in\nthe [xrpc-server skill](../../xrpc-server/SKILL.md).\n\n`satisfies …$Output` on the returned envelope also pins the literal and works\nin either form, but it is verbose and the repo does not use it. If a return\nneeds an explicit contract, the one real precedent annotates the _body_:\n\n```ts\nbody: { /* … */ } satisfies app.bsky.unspecced.getConfig.$OutputBody,\n```\n\n(`$Output` is the whole `{ encoding, body }` envelope; `$OutputBody` is just\nthe body. Mixing them up produces a confusing excess-property error.)\n\n## Type-only references\n\n```diff\n- import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed'\n+ import { app } from '../../../../lexicons/index.js'\n- type Params = QueryParams\n+ type Params = app.bsky.feed.getAuthorFeed.$Params\n```\n\n| Legacy generated type                | Accessor                                    |\n| ------------------------------------ | ------------------------------------------- |\n| `QueryParams` / `InputSchema` params | `$Params`                                   |\n| `OutputSchema`                       | `$OutputBody`                               |\n| whole handler output                 | `$Output`                                   |\n| `InputSchema` (procedures)           | `$Input`                                    |\n| subscription message union           | `$Message`                                  |\n| record / main object                 | `.Main`                                     |\n| sub-definitions                      | their own name (`.ReplyRef`, `.ViewRecord`) |\n\nBecause the namespace object carries both values and types, one\n`import { app } from '../lexicons/index.js'` replaces every per-method type\nimport in the file. Use `import type` when the file needs only types.\n\n## `$lxm` in auth and proxy code\n\nMethod-id strings that used to come from `ids`:\n\n```diff\n- const aud = computeProxyTo(ctx, req, ids.AppBskyActorGetPreferences)\n+ const aud = computeProxyTo(ctx, req, app.bsky.actor.getPreferences.$lxm)\n```\n\nInside a handler where only the schema object is in scope, `l.getMain(ns)`\nreaches the underlying method definition (`.output.schema`, `.$type`) — used by\n`pds`'s read-after-write helpers and `bsky`'s data-plane record routes.\n\n## Subscriptions\n\nRegister the same way; the handler is an async generator and messages are\nbuilt with `$build`:\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    yield com.atproto.sync.subscribeRepos.commit.$build({ seq, time, ...evt })\n  },\n)\n```\n\n`signal` is an `AbortSignal` on the subscription context — pass it to whatever\nproduces events so client disconnects unwind the generator. See\n[packages/pds/src/api/com/atproto/sync/subscribeRepos.ts](../../../../packages/pds/src/api/com/atproto/sync/subscribeRepos.ts).\n\n## Errors thrown by handlers\n\nNothing to do. `InvalidRequestError`, `AuthRequiredError`, `ForbiddenError`,\n`InternalServerError`, `UpstreamFailureError`, `UpstreamTimeoutError`,\n`NotEnoughResourcesError`, and `MethodNotImplementedError` already come from\n`@atproto/xrpc-server` in unmigrated packages — the legacy generated tree never\nre-exported them. Leave those imports alone.\n\nWhat does change is errors the service _catches_ from its own outbound calls —\nthose move from `@atproto/xrpc`'s `XRPCError` to `XrpcError` from\n`@atproto/lex`. `XrpcError` is abstract: catch and narrow it, never construct\nit. That half belongs to\n[lexification-client](../../lexification-client/SKILL.md).\n\n## Header maps\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAliased because `Headers` collides with the DOM global. The types are not\nidentical: legacy `HeadersMap` was `Record<string, string | undefined>`, the\nnew `Headers` is `Record<string, string>`. Code that relied on assigning\n`undefined` to clear a header needs the key omitted instead.\n","isInternal":false,"tokens":1550,"sizeBytes":6662},{"name":"types-and-guards.md","path":".agents/skills/lexification-server/references/types-and-guards.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lexification-server/references/types-and-guards.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Constants, guards, and branded types\n\nRead this for steps 4 and 6: replacing `ids.*`, the generated `isX()` guards,\ntoken constants, and hand-written `$type` literals; then tightening string\ntypes at boundaries.\n\n## `ids.*` → `$type` / `$lxm`\n\nThe `ids` object is gone. Which accessor replaces it depends on what the\nstring is _for_:\n\n```diff\n- import { ids } from '../../../lexicon/lexicons'\n- if (uri.collection === ids.AppBskyGraphList) {\n+ import { app } from '../../../lexicons/index.js'\n+ if (uri.collection === app.bsky.graph.list.$type) {\n```\n\n```diff\n- method === ids.AppBskyFeedGetFeedSkeleton\n+ method === app.bsky.feed.getFeedSkeleton.$lxm\n```\n\n- `$type` — record and object type strings (collections, union tags).\n- `$lxm` — XRPC method ids (auth scopes, proxy targets).\n- `$nsid` — the raw NSID; the fallback for `defs` documents that define no\n  `main` type and therefore have no `$type`.\n\nUse the accessor, not a string literal. A literal is untracked when an NSID is\nrenamed and invisible to the tree-shaking that keeps generated output small.\n\n## `isX()` → `$isTypeOf` or `$matches`\n\nLegacy codegen emitted one `isX()` per definition that checked only the\n`$type` field. The new tree splits that into two accessors, and choosing\ncorrectly matters:\n\n- **`$isTypeOf`** — reads the `$type` tag only. A type predicate, so TS\n  narrows automatically inside `.find()` / `.filter()` / `if`. Correct for\n  discriminating a union whose members were already validated (schema output,\n  hydrated views, anything that came through `$parse`).\n- **`$matches`** — validates the entire value against the schema. Correct\n  when the data's shape is not yet guaranteed: raw records off the firehose,\n  values out of the datastore, third-party input.\n\n`$isTypeOf` is the direct behavioral equivalent of the old `isX()`, and it is\nwhat the real migration used:\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { … }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { … }\n```\n\n```diff\n- repost: isSkeletonReasonRepost(item.reason) ? … : undefined,\n+ repost: app.bsky.feed.defs.skeletonReasonRepost.$isTypeOf(item.reason) ? … : undefined,\n```\n\nHand-written `$type` comparisons collapse the same way:\n\n```diff\n- prefs.find((pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` deliberately, at the point where untrusted data enters —\nin `bsky` that is the record indexing plugins and hydration, in `pds` it is\nre-reading a stored record. Both packages use it a dozen times total against\n~50 uses of `$isTypeOf`.\n\nValidation that returns a result rather than a boolean is `$safeValidate` (or\n`$validate` to throw). See [lex-schema](../../lex-schema/SKILL.md).\n\n## Token values\n\n```diff\n- import { CURATELIST, MODLIST } from '../../../../lexicon/types/app/bsky/graph/defs'\n+ import { app } from '../../../../lexicons/index.js'\n+ const CURATELIST = app.bsky.graph.defs.curatelist.$token\n+ const MODLIST = app.bsky.graph.defs.modlist.$token\n```\n\nUse `$token`, per [STYLE_GUIDE.md](../../../../STYLE_GUIDE.md). It reads as one\nof the `$`-prefixed schema constants (`$type`, `$lxm`, `$nsid`) rather than as a\nfield on the value. Some older call sites use `.value`, which resolves to\nthe same string — replace them if you are editing that file.\n\n## `$build()` for `$type`-carrying values\n\nObject literals that hand-wrote a `$type` field:\n\n```diff\n- return {\n-   $type: 'app.bsky.graph.defs#relationship',\n-   did,\n-   following: subject.following,\n- }\n+ return app.bsky.graph.defs.relationship.$build({\n+   did,\n+   following: subject.following,\n+ })\n```\n\n`$build` fills in `$type` and types the result, so a renamed definition\nbecomes a compile error instead of a silently wrong tag. It is also how\nsubscription messages are emitted (see\n[routes.md](routes.md#subscriptions)).\n\nValues that carry no `$type` need no builder — a type annotation is enough,\nand that is what most def usages become:\n\n```ts\ntype CodeDetail = com.atproto.server.defs.InviteCode\n```\n\n## Centralized type-alias files\n\nA package with a `types.ts` re-exporting from `src/lexicon/types/…` repoints\nit at the generated tree. `packages/bsky/src/views/types.ts` is the live\nexample; copy its conventions rather than inventing names:\n\n```ts\nimport { app, com } from '../lexicons/index.js'\n\nexport type ProfileRecord = app.bsky.actor.profile.Main\nexport const isProfileRecordType = app.bsky.actor.profile.$isTypeOf\n\nexport type StrongRef = com.atproto.repo.strongRef.Main\nexport const validateStrongRef = com.atproto.repo.strongRef.$safeValidate\n```\n\n- Types: `export type Foo = ns.path.TypeName` — `.Main` for the main\n  definition, the def's own name otherwise.\n- Guards: `export const isFooType = ns.path.$isTypeOf`. The `…Type` suffix is\n  intentional; it distinguishes a tag check from a full validation and avoids\n  colliding with the legacy `isFoo` names being replaced.\n- Validators: `export const validateFoo = ns.path.$safeValidate`.\n\nGrouping by NSID prefix with a comment header keeps a 200-line alias file\nnavigable.\n\n## Branded strings\n\nApply at type boundaries — function signatures, interface fields, DB row\ntypes — and leave runtime code alone:\n\n```diff\n- did: string                → did: DidString\n- handle: string             → handle: HandleString\n- handleOrDid: string        → handleOrDid: AtIdentifierString\n- indexedAt: string          → indexedAt: DatetimeString\n- iss: string                → iss: DidString | `${DidString}#${string}`\n```\n\nReplace prefix sniffing with the real guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nPrefer guards over assertion helpers: a guard lets the caller decide what a\nmalformed value means, while an assertion turns it into a 500.\n\nData crossing a boundary TypeScript can't see — protobuf messages, Kysely\nrows, `JSON.parse` — arrives as plain `string`. Cast once, at that entry\npoint:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n\n- qb.where('actor.did', '=', filter.sub!)\n+ qb.where('actor.did', '=', filter.sub! as DidString)\n```\n\nCasting later, after the value has been passed around, means every\nintermediate signature stays `string` and the brand buys nothing.\n\nThese types are re-exported from `@atproto/syntax` unchanged, so\n`import { DidString } from '@atproto/lex'` and\n`from '@atproto/syntax'` are the same type — migrated files use both, and\nfiles already importing other things from `@atproto/syntax` keep doing so.\nFull list and semantics: [lex-data skill](../../lex-data/SKILL.md).\n","isInternal":false,"tokens":1739,"sizeBytes":6759},{"name":"SKILL.md","path":".agents/skills/lexification-server/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/lexification-server/SKILL.md","title":"lexification-server","category":"anthropic-skill","format":"markdown","content":"---\nname: lexification-server\ndescription: >\n  Migrate a service package off the legacy server stack — `lex gen-server`\n  codegen, a committed `src/lexicon/` tree, `server.<ns>.<method>()` route\n  chains, `@atproto/api` inside service code — onto `@atproto/lex` and\n  `@atproto/xrpc-server`. Use when asked to lexify, modernize, or migrate a\n  server package; when a route file imports `Server` or `createServer` from\n  `../lexicon/index.js`; when replacing `ids.*` constants, generated `isX()`\n  guards, `server.xrpc.router`, or `QueryParams`/`OutputSchema` type imports;\n  and when a package still runs `lex gen-server` in its `codegen:lex` script.\n  For the calls a service makes *out* to other services, use\n  lexification-client. For writing new routes on an already-migrated package,\n  use xrpc-server.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating a service package\n\nMoves a package from `@atproto/lex-cli`'s `lex gen-server` output to\n`@atproto/lex` schemas plus `@atproto/xrpc-server` routing.\n\nEvery service in this repo — `packages/pds`, `packages/bsky`, `packages/ozone` —\nis already migrated, so this skill now applies to packages outside the repo, or\nto a stray route chain left behind. **Read the migrated services before\ninventing a pattern.**\n\n## Which skill applies\n\n| Situation                                                    | Skill                                                  |\n| ------------------------------------------------------------ | ------------------------------------------------------ |\n| Package has `src/lexicon/` (singular) and route chains       | **this skill**                                         |\n| Adding a route to a package already on `server.add()`        | [xrpc-server](../xrpc-server/SKILL.md)                 |\n| The same package's outbound `AtpAgent` / `agent.api.…` calls | [lexification-client](../lexification-client/SKILL.md) |\n\nA service migration almost always needs this skill _and_\nlexification-client: one package both serves routes and calls other services.\nDo the server half first — it forces `src/lexicons/` into existence, which the\nclient half then imports from.\n\n## Guiding principles\n\n- **Type-level changes over runtime changes.** Working runtime logic stays;\n  what changes is where types and constants come from. A migration diff that\n  alters behavior is a bug in the migration.\n- **Migrate one namespace of route files at a time**, building in between.\n  A route file is self-contained, so a broken one doesn't block the rest.\n- **Leave test call sites on `AtpAgent`.** Tests double as the regression\n  check that the migration didn't change behavior — swapping the client they\n  use destroys that signal. Tests that import _types or constants_ from the\n  deleted `src/lexicon/` do have to move (details below).\n\n## Order of work\n\n1. **Project config** — swap codegen, deps, gitignore, delete `src/lexicon/`.\n   → [references/project-config.md](references/project-config.md)\n2. **Server bootstrap** — `createServer`, `Server` type, Express mount.\n3. **Route files** — `server.<ns>.<method>(…)` → `server.add(schema, …)`.\n   → [references/routes.md](references/routes.md)\n4. **Constants and guards** — `ids.*`, `isX()`, token exports, `$build`.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n5. **Outbound calls** — [lexification-client](../lexification-client/SKILL.md).\n6. **Branded types** at function/interface/DB boundaries.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n\nSteps 1–2 are a single non-splittable commit (the package won't compile\nin between). Steps 3–6 are incremental.\n\n## Server bootstrap\n\nThe only structural change in the whole migration. `createServer` and `Server`\nstop coming from generated code:\n\n```diff\n- import { createServer } from './lexicon/index.js'\n+ import { createServer } from '@atproto/xrpc-server'\n\n- let server = createServer({\n+ const server = createServer([], {\n    validateResponse: config.debugMode,\n    payload: { /* … */ },\n  })\n\n- app.use(server.xrpc.router)\n+ app.use(server.router)\n```\n\nThe new first argument is an array of legacy `LexiconDoc`s for routes not\nbacked by a generated schema; schema-based setups pass `[]`. Option names\n(`validateResponse`, `payload`, `catchall`, `rateLimits`, `errorParser`) are\nunchanged — see [packages/pds/src/index.ts](../../../packages/pds/src/index.ts).\n\nIn route files, `Server` comes from the same package:\n\n```diff\n- import { Server } from '../../../../lexicon/index.js'\n+ import type { Server } from '@atproto/xrpc-server'\n```\n\n## Import mapping\n\n| Before                                                 | After                                                                |\n| ------------------------------------------------------ | -------------------------------------------------------------------- |\n| `../lexicon` (`Server`, `createServer`)                | `@atproto/xrpc-server`                                               |\n| `../lexicon/lexicons` (`ids`)                          | `../lexicons/index.js` → `.$type` / `.$lxm`                          |\n| `../lexicon/types/…` (record & def types)              | `../lexicons/index.js` → `.Main`, `.ViewRecord`, …                   |\n| `../lexicon/types/…` (`QueryParams`, `OutputSchema`)   | `../lexicons/index.js` → `.$Params`, `.$OutputBody`                  |\n| `../lexicon/types/…` (`isX`, `validateX`)              | `../lexicons/index.js` → `.$isTypeOf`, `.$matches`, `.$safeValidate` |\n| `../lexicon/util` (`$Typed`, `Un$Typed`)               | `@atproto/lex`                                                       |\n| `@atproto/api` (`AtpAgent`)                            | `@atproto/lex` (`Client`)                                            |\n| `@atproto/lexicon` (`jsonStringToLex`, `stringifyLex`) | `@atproto/lex` (`lexParse`, `lexStringify`)                          |\n| `@atproto/lexicon` (`BlobRef`)                         | `@atproto/lex` (`BlobRef`)                                           |\n| `@atproto/xrpc` (`HeadersMap`)                         | `@atproto/xrpc-server` (`Headers`, usually aliased)                  |\n| `@atproto/xrpc` (`XRPCError`, thrown by a _caller_)    | `@atproto/lex` (`XrpcError`)                                         |\n| `multiformats/cid` (`CID`)                             | `@atproto/lex` (`Cid`, `parseCid`)                                   |\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for errors handlers **throw**.\nThe casing distinguishes them: `XRPCError` server-side (concrete, thrown),\n`XrpcError` client-side (abstract, caught). Both can appear in one file.\n\n`@atproto/lex` re-exports `lex-client`, `lex-schema`, `lex-data`, and\n`lex-json`, so `Cid`, `BlobRef`, `lexParse`, and `$Typed` all resolve from it.\n`@atproto/lex-cbor` is **not** re-exported. Importing from either the umbrella\nor the sub-package is fine; match whatever the file already does.\n\n## Pitfalls\n\n- **`$type` vs `$lxm` vs `$nsid`.** `$type` is the record/object type string\n  (`app.bsky.feed.post.$type` → `'app.bsky.feed.post'`), used for collections\n  and union tags. `$lxm` is the method id, used in auth and proxy checks.\n  `$nsid` is the raw NSID, needed for `defs` documents that have no `main`.\n  They can hold identical strings, so a wrong one type-checks and only fails\n  at runtime.\n- **`$matches` vs `$isTypeOf`.** `$isTypeOf` only reads the `$type` tag;\n  `$matches` validates the whole value. Substituting `$isTypeOf` where the\n  data is unvalidated silently accepts malformed records. See\n  [references/types-and-guards.md](references/types-and-guards.md).\n- **`encoding` widening** affects only the bare-handler form of `server.add`,\n  not the object form. Don't sprinkle `as const` everywhere — see\n  [references/routes.md](references/routes.md).\n- **Branded strings arriving from outside TypeScript's view** — protobuf,\n  Kysely rows, `JSON.parse` — are plain `string`. Cast once at the boundary\n  (`as DidString`), never deep in the call graph.\n- **Generated `src/lexicons/` is gitignored.** It won't exist until\n  `pnpm run codegen` (or a build) runs. \"Cannot find module '../lexicons/index.js'\"\n  means codegen hasn't run, not that the import is wrong.\n\n## Tests\n\nTest _call sites_ keep using `AtpAgent`, deliberately (see principles above).\n`dev-env` exposes both `getAgent(): AtpAgent` and `getClient(): Client`, so a\nnew-style client is available when a test genuinely needs one.\n\nWhat must move is anything a test imported from the now-deleted\n`src/lexicon/`. Two valid destinations, both already used in migrated\npackages:\n\n```diff\n- import { ids } from '../../src/lexicon/lexicons'\n- import { isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'\n+ import { com } from '../../src/lexicons/index.js'      // generated schemas\n+ import { $Typed, ComAtprotoAdminDefs, ids } from '@atproto/api'  // legacy equivalents\n```\n\nPrefer `../../src/lexicons/index.js` for new code; `@atproto/api` is the\nlower-churn option when the surrounding test is otherwise untouched. Do not\nadd `@atproto/api` back to a package's dependencies just for this — it is a\ndevDependency in `pds` and only a runtime dependency in `bsky` because of\n`getAgeAssuranceRegionConfig`, unrelated to tests.\n\nBefore writing or restructuring tests, use the\n[testing skill](../testing/SKILL.md) — `pds` and `ozone` are jest, `bsky` is\nvitest.\n\n## Related skills\n\n[xrpc-server](../xrpc-server/SKILL.md) documents the target route API in full\nand is the better reference once a package is migrated.\n[lex-setup](../lex-setup/SKILL.md) owns codegen wiring,\n[lex-schema](../lex-schema/SKILL.md) the `$`-accessors, and\n[lex-data](../lex-data/SKILL.md) branded strings, CIDs, and blobs.\n","frontmatter":{"name":"lexification-server","description":"Migrate a service package off the legacy server stack — `lex gen-server` codegen, a committed `src/lexicon/` tree, `server.<ns>.<method>()` route chains, `@atproto/api` inside service code — onto `@atproto/lex` and `@atproto/xrpc-server`. Use when asked to lexify, modernize, or migrate a server package; when a route file imports `Server` or `createServer` from `../lexicon/index.js`; when replacing `ids.*` constants, generated `isX()` guards, `server.xrpc.router`, or `QueryParams`/`OutputSchema` type imports; and when a package still runs `lex gen-server` in its `codegen:lex` script. For the calls a service makes *out* to other services, use lexification-client. For writing new routes on an already-migrated package, use xrpc-server.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":2412,"sizeBytes":9800},{"name":"SKILL.md","path":".agents/skills/playwright/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/playwright/SKILL.md","title":"playwright","category":"anthropic-skill","format":"markdown","content":"---\nname: playwright\ndescription: >\n  Drive and inspect this repo's OAuth provider UI and Account Manager (source in\n  packages/oauth/oauth-provider-ui, served by the PDS at /account). Use when\n  writing or extending the puppeteer-based UI tests in packages/pds/tests\n  (oauth.test.ts, account-manager.test.ts), when you need the exact visible\n  label, heading, or error string a screen renders before asserting on it, when\n  demoing, navigating or debugging an OAuth sign-in / sign-up / consent flow\n  locally or against production, when exercising account management (password,\n  email, handle, device sessions, deactivation, deletion), or when a question\n  about behavior can only be answered from the rendered page. Use it even when\n  the request sounds like plain test-writing (\"add a test for the reset-password\n  error case\") — getting the assertion strings right is the whole problem.\ndisable-model-invocation: false\n---\n\n# OAuth & Account Manager UI\n\nThe UI lives in [`packages/oauth/oauth-provider-ui`](../../../packages/oauth/oauth-provider-ui) (React 19, TanStack Router, Radix, Lingui i18n) and is served by the PDS through the OAuth provider's middleware. That package has its own [CLAUDE.md](../../../packages/oauth/oauth-provider-ui/CLAUDE.md) covering component structure and i18n — read it before editing the UI itself.\n\nThere is **no Playwright test harness in this repo**: no `playwright` / `@playwright/test` dependency, no `playwright.config.*`, no `.spec.ts` files. The browser tests are jest + **puppeteer** (`puppeteer` is a devDependency of `packages/pds` only). \"Playwright\" in this skill's name refers to driving a browser interactively for discovery and debugging, not to the test runner.\n\n## Get the strings without a browser first\n\nThe tests assert **French** strings, because they set `languages = ['fr-BE', 'fr', 'en-US', 'en']` to exercise locale negotiation. Booting a stack and spoofing a locale just to read a label is slow and easy to get wrong. Two faster sources:\n\n1. **The message catalog** — [`packages/oauth/oauth-provider-ui/src/locales/fr/messages.po`](../../../packages/oauth/oauth-provider-ui/src/locales/fr/messages.po) maps every source string to its French translation, with the defining component in a `#:` comment above it. Grep it for the English string you saw in the JSX and you have the exact assertion text.\n\n   ```\n   #: src/pages/account/(authenticated)/route.tsx\n   msgid \"My Atmosphere Account\"\n   msgstr \"Mon compte Atmosphère\"\n   ```\n\n   Watch for `msgctxt` — the same English word can have several entries (`\"Sign in\"` under `msgctxt \"verb\"` is `\"Se connecter\"`; the noun form differs).\n\n2. **The mock UI dev server** — renders the real components against a fake API, so no PDS, postgres, redis, or docker is involved:\n\n   ```sh\n   cd packages/oauth/oauth-provider-ui\n   pnpm dev:ui   # http://localhost:5174\n   ```\n\n   Pages: `/account-page.html`, `/authorization-page.html`, `/error-page.html`, `/cookie-error-page.html`. Fixtures (accounts, device sessions, branding) are in [`src/mock-api.ts`](../../../packages/oauth/oauth-provider-ui/src/mock-api.ts) — edit them to reach a state the real backend makes awkward (deactivated account, unverified email, many device sessions). It renders in your browser's own locale, so use it for layout and flow, and the `.po` file for the exact French text.\n\nBoot the full stack only when you need real backend behavior: token issuance, email tokens, PLC propagation, handle resolution, deactivation side effects.\n\n## Booting the real stack\n\n`packages/dev-env` wraps startup in [`with-test-redis-and-db.sh`](../../../packages/dev-infra/with-test-redis-and-db.sh), which needs **docker** and **jq** (it falls back to a native postgres/redis if docker is unavailable). Build first — it runs the compiled `dist/bin.js`, not the sources:\n\n```sh\npnpm install && pnpm build       # once, from the repo root\ncd packages/dev-env && pnpm dev  # PDS on :2583, PLC on :2582, appview on :2584\n```\n\nRun `pnpm dev:ts` from the root in another terminal if you're editing TypeScript alongside.\n\n- Account Manager: `http://localhost:2583/account`\n- Seeded accounts ([`packages/dev-env/src/mock/index.ts`](../../../packages/dev-env/src/mock/index.ts)): `alice.test`, `bob.test`, `carla.test`, all `hunter2`.\n- Setting `NODE_ENV=development` (which `pnpm dev` does) makes the OAuth provider log server-side errors and include stack traces in error responses — worth having when a flow fails opaquely.\n\n### Demo OAuth client\n\n```sh\ncd packages/oauth/oauth-client-browser-example\npnpm dev   # http://127.0.0.1:8080\n```\n\nBackend selection is the `env` query parameter, resolved in [`src/constants.ts`](../../../packages/oauth/oauth-client-browser-example/src/constants.ts). It defaults to `NODE_ENV`, which `pnpm dev` sets to `development` — so plain `http://127.0.0.1:8080/` already points at the local stack and `?env=development` is redundant.\n\nOnly `development` targets the local stack — every other value (including `test` and any typo) falls back to `bsky.social` / `api.bsky.app` for handle resolution and the appview. `development` and `test` additionally set `allowHttp` on the OAuth client, which is what lets either talk to a plain-HTTP loopback PDS.\n\n`?env=test` is therefore **not** a local-stack shortcut. It exists for `oauth.test.ts`, which overrides every endpoint explicitly via query params (`pds_operator_url`, `plc_directory_url`, `handle_resolver`, …) and serves its own bundle through `oauthClientAssetsMiddleware` on a random port, not 8080. Used by hand without those overrides it points at production with HTTP allowed — use `development` instead.\n\nAgainst `production`, sign in only with accounts you own — this is a real authorization grant against real infrastructure, not a sandbox.\n\n## Browser automation: confirm what you have\n\nThis repo registers two browser MCP servers in [`.mcp.json`](../../../.mcp.json) — `playwright` (`@playwright/mcp`) and `chrome-devtools` (`chrome-devtools-mcp`), both launched via `pnpx`. Registration is not connection: the server still has to be enabled and started in your harness, and permissions alone don't make tools exist. Run `claude mcp list` and work with what is actually connected rather than assuming `browser_*` tools are available.\n\nIf the Playwright server is registered but has no browser to drive:\n\n```sh\npnpx @playwright/mcp install-browser chrome-for-testing\n```\n\n**Known trap:** the repo root `package.json` declares `devEngines.packageManager: pnpm` with `onFail: \"error\"`, so **any `npx` invocation fails from the repo root** — including the ones MCP plugins use to launch their servers (`npx @playwright/mcp@latest`, `npx chrome-devtools-mcp@…`). Claude Code spawns MCP servers with cwd set to the project root, so a plugin-provided server can fail its health check here while working fine elsewhere. If `claude mcp list` reports a browser server as failed to connect, this is the likely cause; `pnpx` is unaffected, so a server entry using `pnpx` instead of `npx` starts cleanly.\n\nIf no browser MCP is reachable and you can't add one, fall back to the mock UI plus the `.po` catalog for discovery, and to running the puppeteer tests with `headless: false` (the option is already there, commented out, in both test files) for interactive debugging.\n\nTool-name note if you do have the Playwright MCP: `browser_navigate({ url })`, `browser_snapshot({ boxes })`, `browser_click({ element, target })`, `browser_type({ element, target, text })`, `browser_fill_form({ fields })`, `browser_wait_for({ text })`, `browser_console_messages({ level })`, `browser_network_requests({ static })`. `target` takes a ref from a prior snapshot or a CSS selector. Prefer `browser_snapshot` over screenshots — it returns the refs you need to interact, and screenshots don't.\n\n## Writing or extending a UI test\n\nThe two browser test files are [`packages/pds/tests/oauth.test.ts`](../../../packages/pds/tests/oauth.test.ts) and [`packages/pds/tests/account-manager.test.ts`](../../../packages/pds/tests/account-manager.test.ts). Read the neighbouring cases first — they are the best available spec for how these flows behave.\n\n**Discover before you assert.** Reading a component tells you what `<Trans>` wraps; it doesn't tell you which branch renders, what the negotiated locale produces, or what a Radix dialog actually puts in the DOM. Get the real strings from the mock UI, a driven browser, or the `.po` catalog, then write the test.\n\nBoth files share the `PageHelper` wrapper in [`packages/pds/tests/_puppeteer.ts`](../../../packages/pds/tests/_puppeteer.ts) — use it rather than raw puppeteer:\n\n- `goto(url)` / `reload()` — navigate.\n- `assertTitle(text)` — waits for network idle, then asserts `document.title`.\n- `clickOnText(text, tag = 'button')` / `clickOnAriaLabel(label, tag = 'button')` / `clickOn(selector)` — each waits for the element to be _visible_ first.\n- `typeInInput(name, text)` (targets `input[name=\"…\"]`) / `typeIn(selector, text)` — both clear the field first and return the `ElementHandle`, so you can `press('Enter')` on it.\n- `ensureTextVisibility(text, tag = 'p', timeout = 5000)` / `ensureNotification(text)` (`tag = 'div'`).\n- `waitForNetworkIdle()`, `navigationClick(text, tag)` / `navigationAction(fn)` for anything triggering a full navigation, `waitForPopup(fn)` → a `PageHelper` for the popup.\n\n`PageHelper` is `AsyncDisposable`; open pages with `await using page = await PageHelper.from(browser, { languages })` so they close even when a test throws.\n\nTwo recurring gotchas visible in the existing tests:\n\n- `clickOnText` matches on substring via puppeteer's `::-p-text()`, so a label that is a prefix of another (\"Réactiver\" inside \"Réactiver le compte\") hits whichever comes first in DOM order. Target the element directly instead — e.g. `clickOn('[role=\"dialog\"] button[type=\"submit\"]')`.\n- Mailed tokens are obtained by spying on the mailer (`jest.spyOn(network.pds.ctx.mailer, 'sendResetPassword')`) and reading `mock.lastCall`; those calls also assert the negotiated `locale`, which is how the i18n path stays covered.\n\n### Running\n\nThese suites are stateful — each `it` builds on the account state the previous one left behind — so run the whole file, never a single case:\n\n```sh\ncd packages/pds\npnpm test -- tests/oauth.test.ts\n```\n\nGo through `pnpm test`: it wraps jest in the postgres/redis script, and `pretest` installs the Chrome build puppeteer needs.\n\n## Related skills\n\n- [testing](../testing/SKILL.md) — runner choice (vitest vs jest), test file location, tsconfig setup. It routes browser-driven work here; come back to it for anything that isn't a UI test. New tests elsewhere in the repo should be vitest — these two files are jest because `pds` hasn't been migrated.\n","frontmatter":{"name":"playwright","description":"Drive and inspect this repo's OAuth provider UI and Account Manager (source in packages/oauth/oauth-provider-ui, served by the PDS at /account). Use when writing or extending the puppeteer-based UI tests in packages/pds/tests (oauth.test.ts, account-manager.test.ts), when you need the exact visible label, heading, or error string a screen renders before asserting on it, when demoing, navigating or debugging an OAuth sign-in / sign-up / consent flow locally or against production, when exercising account management (password, email, handle, device sessions, deactivation, deletion), or when a question about behavior can only be answered from the rendered page. Use it even when the request sounds like plain test-writing (\"add a test for the reset-password error case\") — getting the assertion strings right is the whole problem.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":2573,"sizeBytes":10837},{"name":"jest.md","path":".agents/skills/testing/references/jest.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/testing/references/jest.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Jest patterns (deprecated)\n\nJest (currently v30) is deprecated here; vitest is the standard. This reference covers adding cases to existing jest files and maintaining them until migration. Don't introduce jest into a package that doesn't already have it — for a fresh package, see [vitest.md](vitest.md).\n\nA package is on jest if it has a `jest.config.cjs` and a `\"test\"` script invoking `jest` with `NODE_OPTIONS=--experimental-vm-modules` (required because the repo is ESM-only). The root [jest.config.cjs](../../../../jest.config.cjs) aggregates them via `projects: ['<rootDir>/packages/*/jest.config.cjs']` — note the single-level glob, so a jest package nested under `packages/lex/` or `packages/oauth/` would not be picked up. Check the package's own config rather than trusting any list.\n\n## TypeScript config\n\nJest packages extend [tsconfig/jest.tsconfig.json](../../../../tsconfig/jest.tsconfig.json), which adds `\"types\": [\"node\", \"jest\"]` on top of the shared node config:\n\n```jsonc\n{\n  \"extends\": [\"../../tsconfig/jest.tsconfig.json\"],\n  \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n  \"compilerOptions\": {\n    \"rootDir\": \".\",\n  },\n  \"references\": [\n    { \"path\": \"./tsconfig.build.json\" },\n    // Integration tests only — see below\n    { \"path\": \"../dev-env/tsconfig.build.json\" },\n  ],\n}\n```\n\n`./tsconfig.build.json` covers the package's own build-time deps and is all most jest packages have. Of the 13, only `api`, `ozone`, `pds`, and `sync` add the `dev-env` entry, because their tests import `@atproto/dev-env` while `src` doesn't. `packages/did` has jest tests but no `tsconfig.test.json` at all — a gap, not a pattern.\n\nThe `include` glob covers colocated `src/**/*.test.ts` even though jest packages barely use them (`packages/api/src/age-assurance.test.ts` is the only one) — leave it as-is so the two runners' configs stay uniform. A package adding custom matchers also needs them declared: [packages/api](../../../../packages/api/tsconfig.test.json) sets `\"types\": [\"jest\", \"./jest.d.ts\"]` to pick up its ambient `jest.d.ts`.\n\n## Writing tests\n\nMost structural conventions from [vitest.md](vitest.md) carry over — `describe`/`it`, `it.each`, fixtures at the top of the file, `using` for spies (jest 30's spies are `Disposable` too). What differs:\n\n| vitest                   | jest                                                                                                                              |\n| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |\n| `vi.fn<FnType>()`        | `jest.fn<FnType>()` (single type param since jest 30)                                                                             |\n| `vi.spyOn` / `vi.mock`   | `jest.spyOn` / `jest.mock`                                                                                                        |\n| `assert()` from `vitest` | `import assert from 'node:assert'` — the prevailing pattern in `pds` tests                                                        |\n| `rejects.toSatisfy(cb)`  | not a jest matcher; use `rejects.toMatchObject({ error: '…' })`, or `const err = await fn().catch((e) => e)` then assert on `err` |\n| `expectTypeOf`           | no equivalent; skip type-level assertions or migrate the file                                                                     |\n\nJest globals are ambient via `@types/jest`, but a minority of files import them explicitly from `@jest/globals`. Match whatever the surrounding files in the package do rather than mixing styles within one package.\n\n## Setup files\n\nTranspilation is `@swc/jest`, not `ts-jest`, with a `moduleNameMapper` that resolves the repo's explicit `.js` import extensions back to `.ts` sources.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests). Jest does accept a path after `--`, but omitting `--` works in both runners — so just use `pnpm test path/to/file.test.ts` and the habit stays correct when the file migrates.\n","isInternal":false,"tokens":914,"sizeBytes":4057},{"name":"vitest.md","path":".agents/skills/testing/references/vitest.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/testing/references/vitest.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Vitest patterns\n\nRepo-specific conventions and setup. Assumes working knowledge of vitest itself (currently v4) — only the things this codebase does differently from the defaults are documented here.\n\n## Adopting vitest in a new package\n\n1. Add `vitest` to `devDependencies` and set `\"test\": \"vitest run\"` in `package.json`.\n2. Create `vitest.config.ts` in the package root. Nearly every package in the repo has exactly this and nothing more:\n\n   ```ts\n   import { defineProject } from 'vitest/config'\n\n   export default defineProject({\n     test: {},\n   })\n   ```\n\n   `defineProject` (not `defineConfig`) is what makes the package composable into the root workspace runner. Add `setupFiles`, `testTimeout`, `resolve.alias`, etc. only when the package actually needs them — [packages/bsky](../../../../packages/bsky/vitest.config.ts) and [packages/ws-client](../../../../packages/ws-client/vitest.config.ts) are the only two that do.\n\n   **Exception:** if the package already has a `vite.config.*`, don't create `vitest.config.ts` — see [Vite-based packages](#vite-based-packages).\n\n3. Register the package in the root [vitest.config.ts](../../../../vitest.config.ts) under `test.projects` so `pnpm test:unit` and the VSCode Vitest extension pick it up. Several entries are globs (`packages/lex/*`, `packages/oauth/*`, `packages/internal/*`), so a package placed under one of those directories needs no edit.\n\n   **Skip this step if the tests need dev-infra** (the postgres + redis docker stack). The workspace runner doesn't start dev-infra, so those tests would fail when invoked from the root. That's why `packages/bsky` is commented out of the list — it must run from its own directory via `pnpm test`, which goes through [with-test-redis-and-db.sh](../../../../packages/dev-infra/with-test-redis-and-db.sh). `packages/lexicon-resolver` is also missing from the list, but without a comment and without a dev-infra wrapper on its `test` script, so treat its absence as an oversight rather than a pattern to copy.\n\n4. Create `tsconfig.test.json` extending the shared vitest config (never the jest one, which pulls in `@types/jest`):\n\n   ```json\n   {\n     \"extends\": [\"../../tsconfig/vitest.tsconfig.json\"],\n     \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n     \"compilerOptions\": {\n       \"rootDir\": \".\"\n     },\n     \"references\": [{ \"path\": \"./tsconfig.build.json\" }]\n   }\n   ```\n\n   Adjust the `../../` depth for nested packages (`packages/lex/*` and `packages/oauth/*` need `../../../`). `./tsconfig.build.json` is normally the only reference needed — it already pulls in the package's build-time deps, so a test importing `@atproto/lex-data` from `lex-json` resolves without an extra entry. Add one only for a package the tests import but `src` doesn't; that's `{ \"path\": \"../dev-env/tsconfig.build.json\" }` in the integration-test packages, and little else.\n\n   Extend `include` for other test-only sources: benchmarks (`./src/**/*.bench.ts`, as [lex-json](../../../../packages/lex/lex-json/tsconfig.test.json) does) or ambient declarations (`./src/core-js.d.ts`, as [lex-document](../../../../packages/lex/lex-document/tsconfig.test.json) does).\n\n5. Make sure the package's `tsconfig.json` references `./tsconfig.build.json`, `./tsconfig.test.json`, and — so the new `vitest.config.ts` gets type-checked — `./tsconfig.config.json`. If the package has no `tsconfig.config.json` yet, create one extending [tsconfig/config.tsconfig.json](../../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]` (adjust the `../../` depth for nested packages). See the SKILL's [TypeScript config](../SKILL.md#typescript-config-for-tests) section.\n\n## Vite-based packages\n\nA package with a `vite.config.*` must use vitest, configured **inside that existing Vite config**. Vitest reads `vite.config.*` natively, so a separate `vitest.config.ts` would shadow the package's real build setup (plugins, `resolve.alias`, `optimizeDeps`, `conditions`) and the tests would run against a different module graph than the app. Jest isn't an option at all here — it can't consume the Vite plugin pipeline.\n\nTwo additions to the Vite config, as in [oauth-provider-ui](../../../../packages/oauth/oauth-provider-ui/vite.config.js):\n\n1. `/// <reference types=\"vitest/config\" />` on the **first line** — this types the `test` key, which `defineConfig` from `vite` doesn't know about on its own.\n2. A `test: {}` key in the exported config, kept empty unless an option is genuinely needed.\n\nEverything else is unchanged: `vitest` in `devDependencies`, `\"test\": \"vitest run\"`, root `projects` registration, and a `tsconfig.test.json`.\n\nBecause the tests share the app's Vite config, they inherit its `resolve.alias` — import through the package's own aliases (`#/lib/foo.js`) exactly as production code does, rather than reaching for brittle relative paths.\n\n## Imports\n\nImport test utilities by name from `vitest`; nothing is ambient:\n\n```ts\nimport { assert, describe, expect, it, vi } from 'vitest'\n```\n\nPrefer vitest's `assert` over `node:assert` — it narrows types and reports through vitest's error formatting.\n\n## Labels\n\n`it` and `test` are aliases at runtime, but this repo picks between them by what the label _is_:\n\n- **`it('<description>', …)`** when the label is a sentence describing behavior. Reads as \"it rejects invalid request bodies\".\n- **`test(case, …)`** when the label identifies a specific case: a function reference, a fixture name, a parameterized row. Reads as \"test `parseCid`\".\n\nFor `describe`, pass the function or class **reference** rather than a string when the suite covers a single named export — the label stays correct through renames:\n\n```ts\ndescribe(parseCid, () => { … })\ndescribe(XrpcResponseError, () => { … })\n```\n\nUse a string label for conceptual groups that don't map to one export — an arrow function assigned to a const, a class method, a roundtrip pair (`describe('roundtrip toBase64 <-> fromBase64', …)`). Integration suites under `./tests` span many endpoints and are string-labeled by convention: `describe('pds profile views', …)`.\n\n## Parameterized tests\n\nPrefer `test.each` over a hand-rolled `for` loop. Each row is a case identifier, hence `test` and not `it`:\n\n```ts\ndescribe(isReconnectableClose, () => {\n  test.each([\n    { note: 'normal shutdown', code: CloseCode.Normal, expected: false },\n    { note: 'internal error', code: CloseCode.InternalError, expected: true },\n  ])('$note', ({ code, expected }) => {\n    expect(isReconnectableClose(code)).toBe(expected)\n  })\n})\n```\n\nThe label template is `'$note'` — `$`-prefixed, referring to a property of the row object. `'{note}'` does _not_ interpolate; it renders literally, so every case ends up with the same name.\n\nTo run one suite against several implementations of an interface, the implementation is the case identifier, so `describe.each` takes it — but the inner labels still describe behavior, so they stay `it`:\n\n```ts\ndescribe.each([utf8LenNode, utf8LenCompute])('%o', (utf8Len) => {\n  // some implementations are runtime-optional; assert before the tests read them\n  assert(utf8Len, 'utf8Len implementation should not be null')\n\n  it('computes utf8 string length', () => {\n    expect(utf8Len('a')).toBe(1)\n  })\n})\n```\n\nFall back to `for (const c of cases) { test(c.name, …) }` only when the body shape genuinely can't be expressed as `test.each` — different nesting or a different number of assertions per case.\n\n### Fixture files\n\nImport JSON fixtures with the import attribute and hand the array straight to `test.each`, keyed on a label property:\n\n```ts\nimport fixtures from './data-model-valid.json' with { type: 'json' }\n\ntest.each(fixtures)('$note', (fixture) => { … })\n```\n\nFor the shared text fixtures in `interop-test-files/`, use the existing [`readInteropFile`](../../../../packages/syntax/tests/_utils.ts) helper rather than re-implementing the read-and-filter logic:\n\n```ts\ntest.each(readInteropFile('aturi_syntax_valid.txt'))('%s', (value) => { … })\n```\n\n## Assertions\n\n### `assert` for narrowing\n\n`assert()` narrows types; `expect(…).toBe(true)` does not. Preferring it means the rest of the test can read narrowed properties without a second guard or a cast:\n\n```ts\nassert(result.success)\nexpect(result.body).toEqual({ value: 'hello' }) // result.body is typed here\n```\n\nThe same applies to instance checks before property assertions:\n\n```ts\nassert(err instanceof XrpcFetchError)\nexpect(err.cause).toBeInstanceOf(TypeError)\n```\n\n`expect(result).toMatchObject({ success: true })` is the worst of the three — no narrowing, and a weaker diagnostic on failure.\n\n### Rejections\n\n- `rejects.toThrow()` — the only assertion is that something threw.\n- `rejects.toThrow(message)` — the only assertion beyond that is the message, as a string or regex.\n- `rejects.toSatisfy(…)` — everything else, including checking exactly one non-message property (`err.cause`, `err instanceof X`, `err.code`).\n\nInside a `toSatisfy` callback, a failing `expect()` or `assert()` propagates as the test failure, so the assertions read normally and no try/catch is needed. The callback has to `return true` at the end — that line is only reached when everything passed:\n\n```ts\nawait expect(someAsyncFn()).rejects.toSatisfy((err) => {\n  assert(err instanceof SomeError)\n  expect(err.cause).toBeInstanceOf(TypeError)\n  return true\n})\n```\n\n### Type-level assertions\n\nUse `expectTypeOf` rather than a hand-rolled `const expectType = <T>(_: T) => {}` helper — it compiles to nothing, so it's free at runtime. This repo uses `toEqualTypeOf` for exact matches and `toMatchObjectType` for partial ones. Avoid `toMatchTypeOf`, which upstream deprecated in favor of those two.\n\n```ts\nexpectTypeOf(result.body).toEqualTypeOf<{ blobRef: l.BlobRef }>()\nexpectTypeOf(response.body).toMatchObjectType<{ cursor?: string }>()\n```\n\nFor deliberately invalid arguments, `// @ts-expect-error` with a reason:\n\n```ts\n// @ts-expect-error intentionally passing invalid params\nparams: { limit: 'not-a-number' },\n```\n\n## Mocks and spies\n\nType `vi.fn` with the function type when the test inspects how it was called:\n\n```ts\nconst fetchHandler = vi.fn<FetchHandler>(async () =>\n  Response.json({ value: 'ok' }),\n)\n\nawait xrpc(fetchHandler, testQuery, { params: { limit: 25 } })\n\nexpect(fetchHandler).toHaveBeenCalledOnce()\nconst [path] = fetchHandler.mock.calls[0]\nexpect(path).toContain('/xrpc/io.example.testQuery')\n```\n\nWhen calls don't need inspecting, a plain typed function is clearer than a mock: `const fetchHandler: FetchHandler = async () => …`.\n\n### Module mocks\n\nMocking a node built-in usually needs both the default and the named exports, since callers may use either form ([lexicon-resolver](../../../../packages/lexicon-resolver/tests/lexicon.test.ts) does this for DNS):\n\n```ts\nvi.mock('node:dns/promises', () => {\n  const mock = { resolveTxt: vi.fn() }\n  return { default: mock, ...mock }\n})\n```\n\nTo override only part of a module, spread the actual one:\n\n```ts\nvi.mock('./const.js', async () => {\n  const actual = await vi.importActual<Record<string, unknown>>('./const.js')\n  return { ...actual, KWS_V2_COUNTRIES: new Set(['AA']) }\n})\n```\n\nNote that `bsky` integration tests mock the **compiled** path (`../../dist/api/…`), not `../../src/…`, because the service under test is loaded from `dist`. Match whatever the module graph actually resolves, or the mock silently won't apply.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests) — in particular, don't put `--` before a file path.\n","isInternal":false,"tokens":2842,"sizeBytes":11601},{"name":"SKILL.md","path":".agents/skills/testing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/testing/SKILL.md","title":"testing","category":"anthropic-skill","format":"markdown","content":"---\nname: testing\ndescription: >\n  Apply this monorepo's testing practices — runner choice (vitest vs jest),\n  where test files belong, tsconfig wiring, and the dev-env fixture used by\n  integration tests. Use before any code search when asked to add, write, or\n  extend tests, add coverage, reproduce a bug with a failing test, choose or\n  configure a test runner, decide where a test belongs, run a single test file,\n  or test a browser-based user flow. Covers unit, integration, and end-to-end\n  tests, and routes UI work to the playwright skill.\ndisable-model-invocation: false\n---\n\n# Testing in this repo\n\n## UI tests: get the assertion strings right first\n\nIf the test drives a UI (`packages/pds/tests/{oauth,account-manager}.test.ts`, or anything using the `puppeteer` `PageHelper`), the assertion strings are the whole problem — they are **French** and branch-dependent, so they can't be guessed from the JSX. Read the playwright skill before asserting on any of them: the fastest source is the `fr` message catalog, with a browser walkthrough as the fallback when the rendered branch is unclear.\n\nFull workflow, `PageHelper` API, and dev-env boot instructions: [playwright skill](../playwright/SKILL.md). Come back here for runner and tsconfig questions.\n\n## Choosing a runner\n\nVitest is the standard and now covers the majority of the repo (~25 packages, ~270 test files) — including `bsky`, all of `packages/lex/*`, `packages/oauth/*`, and the tested `packages/internal/*`. Jest remains in 13 unmigrated packages (~165 files), among them `pds`, `ozone`, `api`, `repo`, and `xrpc-server`. Jest is deprecated: don't introduce it anywhere new, and prefer migrating a file to vitest over deepening its jest usage.\n\nDecide from the package's own config files, not from a memorized list — they are the only drift-proof signal:\n\n1. Package has a `vite.config.*` → **Vitest, configured inside that Vite config** (never a separate `vitest.config.ts`). See [Vite-based packages](references/vitest.md#vite-based-packages).\n2. Package has a `vitest.config.ts` → Vitest. See [references/vitest.md](references/vitest.md).\n3. Package has a `jest.config.cjs` → Jest. See [references/jest.md](references/jest.md).\n4. None of the above → the package has no test setup. Adopt vitest: create `vitest.config.ts` and `tsconfig.test.json` first, following [the setup section](references/vitest.md#adopting-vitest-in-a-new-package), then write the test.\n\nIf the test imports `@atproto/lex` or any `@atproto/lex-*` package, also read the [lex-schema skill](../lex-schema/SKILL.md) — schemas expose `$parse` / `$safeParse` / `$matches`, which make better assertions than hand-rolled shape checks.\n\n## Where tests go\n\n- **Unit tests** sit next to their subject: `foo.ts` + `foo.test.ts`. This is the default whenever the test needs no infra and no cross-package setup.\n- **Integration / end-to-end tests** go in the package's top-level `./tests` folder (`packages/{pds,bsky,ozone}/tests`, `packages/lex/lex/tests`, …). Use it for tests that boot real services, hit a database, or exercise several modules together.\n- **Shared helpers** go in `./tests/_util.ts` (or a `./tests/_util/` directory). The leading underscore keeps them out of glob-based test discovery — see [packages/bsky/tests/\\_util.ts](../../../packages/bsky/tests/_util.ts) and [packages/ws-client/tests/\\_util/](../../../packages/ws-client/tests/_util).\n\nJest packages predate the colocation convention and keep everything in `./tests` — `packages/api/src/age-assurance.test.ts` is the lone exception. Don't relocate existing files just to match the convention.\n\n## Integration tests use the dev-env fixture\n\nAlmost every integration test in `pds`, `bsky`, `ozone`, and `lexicon-resolver` boots a real service constellation through `@atproto/dev-env` rather than mocking. Reach for it instead of hand-wiring servers:\n\n- `TestNetwork.create({ dbPostgresSchema })` — PDS + AppView + bsync + ozone + PLC. Needs postgres and redis. The schema must be unique per test file: it's the isolation boundary (the AppView and ozone databases are derived from it), so parallel files sharing one corrupt each other's data.\n- `TestNetworkNoAppView.create({ pds, plc })` — PDS + PLC only, on a temp-dir store and a mock PLC database. Lighter; use it when the test doesn't touch the AppView. It forwards only the `pds` and `plc` sub-options, so passing a top-level `dbPostgresSchema` here does nothing.\n- `TestPds.create(config, overrides)` — a lone PDS; pair with `TestPlc.create({})` and set `didPlcUrl: plc.url`. The optional second argument is passed to `PDS.create` as `AppContext` overrides (e.g. `{ plcRotationKey }`); neither network class forwards it.\n- `network.getSeedClient()` plus a seed exported from `@atproto/dev-env` (`basicSeed`, `usersSeed`, `quotesSeed`, …) populates accounts and records.\n- `await network.processAll()` flushes the firehose so the AppView has caught up before you assert.\n\nTear down with `afterAll(async () => network?.close())` — the prevailing pattern, and the only option for `TestNetworkNoAppView`, which exposes `close()` but no `Symbol.asyncDispose`. Only `TestNetwork` implements it.\n\nA test that resolves identities (handles, `did:plc`, `did:web`) against localhost must construct its resolver with `fetch: globalThis.fetch` — `@atproto/identity`'s default fetch is SSRF-protected and refuses localhost/private-IP targets silently.\n\nSnapshot assertions go through each package's `forSnapshot()` helper in `tests/_util.ts`, which swaps DIDs, CIDs, and timestamps for stable placeholders so snapshots don't churn on every run. Refresh them with `pnpm test:updateSnapshot` (defined in `bsky`, `pds`, `ozone`, `bsync`).\n\n## TypeScript config for tests\n\nTested packages split their TS config, each part referenced from the package's `tsconfig.json`:\n\n```json\n{\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.config.json\" },\n    { \"path\": \"./tsconfig.build.json\" },\n    { \"path\": \"./tsconfig.test.json\" }\n  ]\n}\n```\n\n- `tsconfig.build.json` — `./src`, excludes `**/*.test.ts`, emits to `./dist`.\n- `tsconfig.test.json` — test code, extending `tsconfig/vitest.tsconfig.json` or `tsconfig/jest.tsconfig.json`. Both set `noEmit` and `composite: false`; the jest one additionally pulls in `@types/jest`. `include` is `[\"./tests\", \"./src/**/*.test.ts\"]` in nearly every package, regardless of runner.\n- `tsconfig.config.json` — root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, …) that neither of the above covers, extending [tsconfig/config.tsconfig.json](../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]`. `exclude` any root file that imports `./src` or `./tests` (e.g. a `jest.setup.ts`), or it drags the whole tree into this stricter program. Present whenever the package has such a file — which is nearly all of them.\n\nThe build is a TS project graph, so `references` is what makes imports resolve. `./tsconfig.build.json` alone covers anything the package already depends on at build time — which is most workspace imports. Add a further entry only for a package the tests import but `src` doesn't: in practice that means `{ \"path\": \"../dev-env/tsconfig.build.json\" }`, and only in the six packages with integration tests (`api`, `bsky`, `lexicon-resolver`, `ozone`, `pds`, `sync`).\n\nCreate `tsconfig.test.json` before writing the first test in a package that lacks one — `packages/did` has jest tests without one, so don't take its layout as the pattern. Exact contents per runner: [vitest](references/vitest.md#adopting-vitest-in-a-new-package) / [jest](references/jest.md#typescript-config).\n\n## Running tests\n\nRun from inside the package directory:\n\n```bash\npnpm test                        # full package suite\npnpm test path/to/file.test.ts   # single file\n```\n\nPackages whose tests need docker infra wrap the runner in a dev-infra script — `bsky`, `pds`, `ozone`, and `sync` use [with-test-redis-and-db.sh](../../../packages/dev-infra/with-test-redis-and-db.sh); `bsync` uses `with-test-db.sh`. Always go through `pnpm test`; invoking `vitest` or `jest` directly skips the script, so postgres and redis aren't running and the suite dies on connection errors. `pds` also offers `pnpm test:sqlite` for a faster loop that skips the docker infra.\n\nFrom the repo root, `pnpm test` runs every package's suite with infra up, and `pnpm test:unit` runs only the projects registered in [vitest.config.ts](../../../vitest.config.ts). That list is not the full set of vitest packages: `bsky` is commented out because it needs infra, and `lexicon-resolver` is simply absent. Run either from its own directory.\n\n## Logs\n\nEvery part of the code base uses pino logging, which is disabled by default. Enable it with `LOG_ENABLED=true` and redirect to a file with `LOG_DESTINATION=...`. For example, to run the full test suite and capture logs:\n\n```bash\nLOG_ENABLED=true LOG_DESTINATION=test.log pnpm test ...\n```\n\nPretty print using:\n\n```bash\ncat test.log | pnpm exec pino-pretty\n```\n\n## Code style\n\n- Always use `using` for spies, mocks and any object that implements `Disposable` or `AsyncDisposable`.\n","frontmatter":{"name":"testing","description":"Apply this monorepo's testing practices — runner choice (vitest vs jest), where test files belong, tsconfig wiring, and the dev-env fixture used by integration tests. Use before any code search when asked to add, write, or extend tests, add coverage, reproduce a bug with a failing test, choose or configure a test runner, decide where a test belongs, run a single test file, or test a browser-based user flow. Covers unit, integration, and end-to-end tests, and routes UI work to the playwright skill.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":2286,"sizeBytes":9204},{"name":"SKILL.md","path":".agents/skills/update-chat-lexicons/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/update-chat-lexicons/SKILL.md","title":"update-chat-lexicons","category":"anthropic-skill","format":"markdown","content":"---\nname: update-chat-lexicons\ndescription: >\n  Sync `chat.bsky.*` Lexicon JSON from the private `bluesky-social/chat` repo\n  into this monorepo, regenerate the code derived from it, and add a changeset.\n  Use whenever asked to update, sync, pull, refresh, or copy chat lexicons, to\n  pick up new or changed chat convo, group, moderation, or notification\n  definitions, or to reconcile `lexicons/chat/` with the chat service —\n  including when the request is just \"update chat lexicons\".\ndisable-model-invocation: false\n---\n\n# Update chat lexicons\n\n`lexicons/chat/bsky/**` is a mirror. The source of truth is the separate\n`bluesky-social/chat` repo, so an update is a copy _plus_ codegen _plus_ a\nchangeset — the copy on its own leaves the generated clients stale.\n\n## 1. Refresh the source checkout\n\nThis assumes a sibling clone at `../chat`. Update it first: step 2 copies with\n`--delete`, so syncing from a checkout that is behind will delete lexicons that\nstill exist upstream.\n\n```bash\ngit -C ../chat pull --ff-only\n```\n\nIf `../chat` doesn't exist, `bluesky-social/chat` is private and not\nweb-fetchable — clone it with `gh repo clone bluesky-social/chat ../chat`, or\nask the user where their checkout lives and substitute that path below. Don't\nimprovise a source from the destination; there would be nothing to sync.\n\n## 2. Copy\n\n```bash\nrsync -a --delete ../chat/lexicons/chat/ ./lexicons/chat/\n```\n\nOnly `lexicons/chat/` is mirrored. The chat repo also carries `app/`, `com/`,\nand `tools/` lexicons that diverge from this repo's — never sync those.\n\nCheck `git status` immediately. Unexpected deletions almost always mean the\nsource checkout is behind, not that definitions were dropped upstream.\n\n## 3. Regenerate\n\n```bash\npnpm codegen\n```\n\nRun this from the repo root, and use `pnpm codegen` rather than `pnpm build`:\n`packages/api` and `packages/ozone` regenerate only when a lexicon file is\n_newer_ than their generated index, and `rsync -a` preserves the source's\nmtimes — so a build can decide nothing changed and silently keep stale types.\n`pnpm codegen` invokes the generators unconditionally, building the codegen\ntooling itself first.\n\nBoth generated trees are gitignored, so the commit diff shows only JSON. The\nreal blast radius stays invisible until you build.\n\n## 4. Changeset\n\nAdd a file under `.changeset/` with a three-word kebab-case name:\n\n```markdown\n---\n'@atproto/api': patch\n---\n\nupdate chat lexicons\n```\n\nA patch on `@atproto/api` alone is the established convention here (see\n`git log --oneline -- lexicons/chat`). `@atproto/ozone` also generates from\nthese lexicons but picks up an automatic dependency bump, so don't list it by\nhand. Use `minor` instead if the sync removes or renames a definition — that\nbreaks the published types.\n\n## 5. Verify\n\n```bash\n(cd packages/api && pnpm build && pnpm test)\n(cd packages/ozone && pnpm build)\n```\n\nOzone's build typechecks its route handlers against the regenerated server\ntypes, which is where a removed or narrowed chat definition actually surfaces.\n\nThen review the diff and commit. Before opening a PR, run the full checks from\nthe root: `pnpm build --force && pnpm verify`.\n","frontmatter":{"name":"update-chat-lexicons","description":"Sync `chat.bsky.*` Lexicon JSON from the private `bluesky-social/chat` repo into this monorepo, regenerate the code derived from it, and add a changeset. Use whenever asked to update, sync, pull, refresh, or copy chat lexicons, to pick up new or changed chat convo, group, moderation, or notification definitions, or to reconcile `lexicons/chat/` with the chat service — including when the request is just \"update chat lexicons\".\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":797,"sizeBytes":3168},{"name":"SKILL.md","path":".agents/skills/xrpc-server/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/.agents/skills/xrpc-server/SKILL.md","title":"xrpc-server","category":"anthropic-skill","format":"markdown","content":"---\nname: xrpc-server\ndescription: >\n  Build or edit routes on an AT Protocol service that uses\n  `@atproto/xrpc-server` — `packages/pds`, `packages/bsky`, or any package whose\n  handlers call `server.add()`. Use when adding or changing an endpoint, wiring\n  `createServer` into Express, reading params/input/auth in a handler, returning\n  JSON, binary, streamed, or empty responses, setting response headers, throwing\n  XRPC protocol errors, applying per-route rate limits or body limits, or\n  exposing a WebSocket subscription. To migrate a service off the legacy\n  generated server stack, use the lexification-server skill instead. For calls\n  the service makes *out* to other services, use the lex-client skill.\ndisable-model-invocation: false\n---\n\n# `@atproto/xrpc-server`\n\nThe server half of the AT Protocol XRPC stack. Service code creates a `Server`\nand registers endpoints by passing generated lexicon schemas to `server.add()`.\n\nImport it directly — it is **not** re-exported by `@atproto/lex`:\n\n```ts\nimport { InvalidRequestError, type Server } from '@atproto/xrpc-server'\n```\n\nThis is the server package the services actually use — `@atproto/lex-server` is\na separate package that no service in this repo consumes. `packages/pds`,\n`packages/bsky` and `packages/ozone` are all fully on `server.add()`.\n\nWatch the casing: `XRPCError` is this package's server-side class, thrown in\nhandlers. `XrpcError` is the _client_-side class from `@atproto/lex-client`.\n\n## Creating the server\n\n```ts\nimport { createServer } from '@atproto/xrpc-server'\n\nconst server = createServer([], {\n  validateResponse: config.debugMode,\n  payload: {\n    jsonLimit: 100 * 1024,\n    textLimit: 100 * 1024,\n    blobLimit: 5 * 1024 * 1024,\n  },\n})\n\napp.use(server.router) // Express\n```\n\nThe first argument is an array of legacy `LexiconDoc`s — pass `[]` for\nschema-based setups. It is not middleware. Other `Options` worth knowing:\n`catchall` (a fallback `RequestHandler`, used by the PDS for proxying),\n`errorParser` (map non-`XRPCError` throwables onto XRPC errors before they are\nrendered), and `rateLimits` (global/shared limiter wiring).\n\n`server.router` is the Express app. Legacy generated servers expose\n`server.xrpc.router` instead — if you see that, you are in legacy code.\n\nWebSocket upgrades are wired up automatically when the server's router is\nmounted on an app that later calls `.listen()`; there is nothing extra to do\nfor subscriptions beyond registering them.\n\n## Registering a route\n\n`server.add(schema, handlerOrConfig)`. The schema is the generated lexicon\nobject, e.g. `com.atproto.identity.resolveHandle`.\n\nBare-function form when the route needs nothing but a handler:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../lexicons/index.js'\n\nexport default function (server: Server, ctx: AppContext) {\n  server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n    const did = await resolve(params.handle)\n    return { encoding: 'application/json', body: { did } }\n  })\n}\n```\n\nObject form for anything else — `auth`, `rateLimit`, `opts`:\n\n```ts\nserver.add(app.bsky.draft.createDraft, {\n  auth: ctx.authVerifier.standard,\n  handler: async ({ input, auth }) => {\n    const actorDid = auth.credentials.iss\n    if (atLimit)\n      throw new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\n    return { encoding: 'application/json', body: { draft } }\n  },\n})\n```\n\nRegistration is conditional-friendly: it is normal to `return` early when a\ndependency is absent (`if (!ctx.bskyAppView) return`), or to register a\ndifferent handler for the same NSID on each branch of an `if`.\n\n### Handler context\n\n`{ params, input, auth, req, res, resetRouteRateLimits }`. Queries read\n`params`, procedures read `input.body`; `req`/`res` are Express objects, useful\nfor reading request headers or setting response headers directly.\n\nThere is **no `signal`** on a method handler — only subscription handlers get\none. `resetRouteRateLimits()` clears the consumed points for this route,\ne.g. after a successful login.\n\n### Route options and rate limits\n\n```ts\nserver.add(com.atproto.repo.createRecord, {\n  auth: ctx.authVerifier.authorization({ authorize: () => {} }),\n  rateLimit: [\n    {\n      name: 'repo-write-hour',\n      calcKey: ({ auth }) => auth.credentials.did,\n      calcPoints: () => 3,\n    },\n  ],\n  opts: { jsonLimit: 1_000_000 },\n  handler: async ({ input, auth }) => {\n    /* ... */\n  },\n})\n```\n\n`rateLimit` is one entry or an array; `{ name }` references a shared limiter\ndeclared in `createServer`'s `rateLimits.shared`, while `{ durationMs, points }`\ndeclares a route-local one. `opts` is `RouteOptions` — `jsonLimit`, `textLimit`,\n`blobLimit`, `paramsParseLoose` — overriding the server-wide `payload` defaults.\n\n## Return values\n\nA JSON response is `{ encoding, body }`, optionally with `headers`:\n\n```ts\nreturn {\n  encoding: 'application/json',\n  body: result,\n  headers: { 'server-timing': serverTimingHeader([timerSkele, timerHydr]) },\n}\n```\n\nOther shapes the server understands:\n\n| Return                           | Effect                                                                   |\n| -------------------------------- | ------------------------------------------------------------------------ |\n| nothing (`undefined`)            | 200 with an empty body — correct for lexicons with no `output`           |\n| `{ encoding, body: Readable }`   | streamed binary response                                                 |\n| `{ encoding, buffer, headers? }` | `HandlerPipeThroughBuffer`                                               |\n| `{ encoding, stream, headers? }` | `HandlerPipeThroughStream` — what the PDS `pipethrough()` helper returns |\n\nBinary example, from `packages/pds/src/api/com/atproto/sync/getBlob.ts`:\n\n```ts\nres.setHeader('content-security-policy', `default-src 'none'; sandbox`)\nreturn {\n  encoding: found.mimeType || ('application/octet-stream' as const),\n  body: found.stream,\n}\n```\n\nBodies are validated against the schema's output unless the server was\nconstructed with `validateResponse: false`.\n\n### When `encoding` needs `as const`\n\nReturning the object literal _directly_ from a handler that declares at least\none parameter is contextually typed by `server.add()`, so `encoding:\n'application/json'` is already narrow and needs nothing — this is what most\nhandlers in `packages/bsky/src/api/` do.\n\nContextual typing is lost in two cases, and then `'application/json'` widens to\n`string` and no longer matches the schema:\n\n- the response is built into an intermediate variable before being returned\n- the handler declares no parameters at all (`async () => ({ ... })`)\n\nFix either with `as const` on the literal, or by anchoring the whole object with\n`satisfies app.bsky.graph.getRelationships.$Output`. A\n`Promise<…$Output>` return-type annotation does **not** fix the\nintermediate-variable case — it produces an assignability error instead.\n\n## Errors\n\nThrow an `XRPCError` subclass. Constructors are\n`(message?, errorName?, options?)`; `errorName` should match an error declared\nin the lexicon, and `options` is standard `ErrorOptions` so causes can be\nchained:\n\n```ts\nthrow new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\nthrow new InvalidRequestError('Unable to resolve handle', undefined, {\n  cause: err,\n})\n```\n\n| Class                       | Status |\n| --------------------------- | ------ |\n| `InvalidRequestError`       | 400    |\n| `AuthRequiredError`         | 401    |\n| `ForbiddenError`            | 403    |\n| `InternalServerError`       | 500    |\n| `MethodNotImplementedError` | 501    |\n| `UpstreamFailureError`      | 502    |\n| `NotEnoughResourcesError`   | 503    |\n| `UpstreamTimeoutError`      | 504    |\n\n`RateLimitExceededError` (429) also exists but takes a `RateLimiterStatus` as its\nfirst argument and is thrown by the rate limiter, not by handlers.\n\nEach subclass overrides `Symbol.hasInstance` to match on the response type\nrather than the prototype chain, so `err instanceof InvalidRequestError` is true\nfor _any_ `XRPCError` with a 400 type, however it was constructed.\n\n`ErrorResult` (`{ status, error?, message? }`) is the non-throwing counterpart,\nreturned by auth verifiers; `excludeErrorResult(v)` converts one back into a\nthrown `XRPCError`.\n\n## Schema accessors inside handlers\n\nThe schema object is in scope — use its accessors instead of NSID string\nliterals (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md)):\n\n```ts\nconst lxm = app.bsky.actor.getPreferences.$lxm // 'app.bsky.actor.getPreferences'\nconst aud = computeProxyTo(ctx, req, lxm)\npermissions.assertRpc({ aud, lxm })\n```\n\n| Accessor        | Use                                                                  |\n| --------------- | -------------------------------------------------------------------- |\n| `$lxm`          | lexicon method id, for auth scopes and proxy targets                 |\n| `$type`         | record type, e.g. `uri.collection === app.bsky.feed.post.$type`      |\n| `$token`        | a token def's string value (`app.bsky.graph.defs.curatelist.$token`) |\n| `$build({...})` | construct a value with its `$type` set                               |\n\n`$build` is how union members are constructed:\n\n```ts\napp.bsky.graph.defs.notFoundActor.$build({ actor, notFound: true })\n```\n\nFor shapes that carry no `$type`, just annotate or `satisfies` the exported type\n(`com.atproto.server.defs.InviteCode`).\n\n## Type-only references\n\n```ts\ntype Params = app.bsky.feed.getAuthorFeed.$Params\ntype Output = app.bsky.feed.getAuthorFeed.$Output // { encoding, body }\ntype Body = app.bsky.feed.getAuthorFeed.$OutputBody // just the body\ntype PostRecord = app.bsky.feed.post.Main\ntype PostView = app.bsky.feed.defs.PostView\n```\n\n`$OutputBody` is the one you want for helper functions that compute a response\nbody; `$Output` includes the `encoding` wrapper. Inputs mirror this with\n`$Input` / `$InputBody`, and subscriptions use `$Message`.\n\n## Subscriptions\n\nRegister a subscription schema with an async generator. Yield `$build()`-ed\nmessages; the server frames and (unless `validateResponse: false`) validates\neach one. `signal` aborts when the client disconnects — thread it into whatever\nproduces events so the generator unwinds.\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    const { cursor } = params\n    const curr = await ctx.sequencer.curr()\n    if (cursor !== undefined && cursor > (curr ?? 0)) {\n      throw new InvalidRequestError('Cursor in the future.', 'FutureCursor')\n    }\n    for await (const evt of outbox.events(cursor, signal)) {\n      yield com.atproto.sync.subscribeRepos.commit.$build({\n        seq: evt.seq,\n        time: evt.time,\n        ...evt.evt,\n      })\n    }\n  },\n)\n```\n\nAnnotating the generator's return type keeps errors on the offending `yield`\nrather than on the whole `server.add()` call. Throwing before the first yield\nrejects the connection with that error. The subscription context is\n`{ params, auth, req, signal }` — and `req` here is a raw Node\n`IncomingMessage`, not an Express `Request`.\n\n`packages/pds/src/api/com/atproto/sync/subscribeRepos.ts` is the only\nsubscription in the repo and the reference implementation.\n\n## File layout\n\nHandlers live under `src/api/<nsid-path>.ts`, one file per method, filename\nmatching the method name in camelCase (the documented exception to the\nkebab-case rule), each default-exporting a registrar:\n\n```\npackages/bsky/src/api/\n  app/bsky/feed/getAuthorFeed.ts   ← export default function (server, ctx) { server.add(...) }\n  com/atproto/identity/resolveHandle.ts\n  index.ts                         ← imports each module and calls it\n```\n\n`src/api/index.ts` is a flat list of imports followed by a flat list of\n`handler(server, ctx)` calls; add new routes in both places.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  `server.add()`, and their `$`-accessors. Needed alongside this skill for\n  almost any route work.\n- **[lex-data](../lex-data/SKILL.md)** — branded strings, `BlobRef`, CBOR, and\n  datetimes at route boundaries.\n- **[lex-client](../lex-client/SKILL.md)** — when the service also calls _out_\n  to other services.\n- **[lexification-server](../lexification-server/SKILL.md)** — migrating an\n  existing service off the legacy `lex gen-server` stack onto the patterns\n  above.\n- **[lex-setup](../lex-setup/SKILL.md)** — lexicon install/build configuration.\n- **[testing](../testing/SKILL.md)** — runner choice and test placement when\n  testing route handlers.\n","frontmatter":{"name":"xrpc-server","description":"Build or edit routes on an AT Protocol service that uses `@atproto/xrpc-server` — `packages/pds`, `packages/bsky`, or any package whose handlers call `server.add()`. Use when adding or changing an endpoint, wiring `createServer` into Express, reading params/input/auth in a handler, returning JSON, binary, streamed, or empty responses, setting response headers, throwing XRPC protocol errors, applying per-route rate limits or body limits, or exposing a WebSocket subscription. To migrate a service off the legacy generated server stack, use the lexification-server skill instead. For calls the service makes *out* to other services, use the lex-client skill.\n","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":3055,"sizeBytes":12706},{"name":"moderation.md","path":"packages/api/docs/moderation.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/api/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  moderationPrefs: {\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: string\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: AppBskyActorDefs.MutedWord[]\n  hiddenPosts: string[]\n}\n\nexport interface ModerationOpts {\n  userDid: string | 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 `agent.getPreferences()` method:\n\n```typescript\nconst prefs = await agent.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 this static function, though the default is to use Bluesky's moderation:\n\n```typescript\nBskyAgent.configure({\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\nUsers may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed.\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 definition 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.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours).\n\nHere is how to do this:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst agent = new AtpAgent({ service: 'https://example.com' })\n// assume `agent` is a signed in session\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\nmoderatePost(post, {\n  userDid: agent.session.did,\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  moderateFeedGen,\n  moderateUserList,\n  moderateLabeler,\n} from '@atproto/api'\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## 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 `Atproto-Proxy` header:\n\n```typescript\nagent\n  .withProxy('atproto_labeler', 'did:web:my-labeler.com')\n  .createModerationReport({\n    reasonType: 'com.atproto.moderation.defs#reasonViolation',\n    reason: 'They were being such a jerk to me!',\n    subject: { did: 'did:web:bob.com' },\n  })\n```\n","isInternal":false,"tokens":1777,"sizeBytes":7238},{"name":"AGENTS.md","path":"packages/bsky/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/bsky/AGENTS.md","title":"Bsky Skill","category":"anthropic-skill","format":"markdown","content":"# AppView hydration\n\n- Endpoint-specific viewer block checks must honor `HydrateCtx.skipViewerBlocks` for the trusted moderation service, while preserving viewer block metadata and mute filtering.\n- In `hydration/external.ts`, keep shared types above `ExternalHydrator`; place helpers and their specific types below the class.\n- `ExternalHydrator` generic record lookups decode record bodies without collection-specific validation. Validate against the relevant Lexicon before using typed fields; do not apply parse-mode defaults to stored records.\n- `getRecordsByURI`/`getRecordsByRef` split input into sequential requests of at most `MAX_RECORDS_PER_REQUEST` (the dataplane rejects more than 100 URIs), pairing results positionally per request. Any failed request rejects the whole call; never return partially fetched batches. Bound requests there, not in callers or traversal.\n- `Views.externalRecordView` consumes `HydrationState.externalRecords` (latest versions), `externalRecordBacklinks` (target URI → available source URI samples), and `externalRecordBacklinkCounts`. Hydrate referenced publications, gallery item/photo records, backlink source records, labels, and profiles before building views. Counts are independent of the sampled previews.\n- External view builders keep strict Lexicon validation (`$matches`). Handle third-party records that violate their own published lexicon with a minimal compatibility override (see `lexicons-overrides/README.md`), not by loosening view validation.\n- Streamplace uses a declared HTTP(S) URL or falls back to its AT URI. A stream is active only without `endedAt` and with a `lastSeenAt` within `LIVESTREAM_HEARTBEAT_WINDOW_MS` (2 minutes) of `now`, on either side; record `idleTimeoutSeconds` and `createdAt` are ignored.\n- Atmosphere Explore uses each worker item’s `canonicalUri` as its sole hydration/view root; a missing or invalid root skips the item with no fallback or backfill. With the `AtmosphereExploreArticlesIrisEnable` gate off, worker groups also supply articles; with it on, up to 10 Iris `site.standard.document` roots (fetched concurrently with the worker) are the exclusive article source when the call succeeds with a nonempty articles list: worker articles are ignored and worker location/topic selection does not apply to them. Every list is capped at 10: selected record-backed lists (including Iris articles) are shuffled before hydration and presentation preserves that order; apps are capped at 10 in worker order. Livestream editorial windows constrain visibility before selection and capping without replacing heartbeat-based liveness. App-profile hydration is batched for the selected apps under the same request context, and upstream-only metadata stays out of public cards. Worker ingestion is best-effort per item: malformed banners, groups, and app cards are skipped without failing siblings or the request; only the first 100 entries per worker section are examined. Call Iris directly with request-scoped headers and abort signals, explicitly enabling XRPC response validation even outside debug mode; do not add a separate response parser. Keep article-root validation and the 10-item cap. A successful empty Iris list falls back to worker-curated articles and retains Iris's rec ID; a nonempty Iris list fully removed by root filtering or hydration does not trigger curated fallback. Catch Iris request and response-validation failures (rethrowing request aborts): log a warning and fall back to worker-curated articles with no rec ID; worker failures stay fatal, and the gate-on missing-Iris-client 501 is unchanged. The worker fetch retains its decoded-byte limit and deadline. Each list is an `{ items, recIdStr? }` section; `articles.recIdStr` comes from a successful Iris response, including the empty-list worker fallback, rather than identifying the source of the returned articles. Keep root validation in `getAtmosphereExploreTab.ts`; one shared hydration traversal discovers publications and other dependencies.\n- Exact-ref hydration maps are keyed by the branded `ExactRecordKey` from `genericRecordKey({ uri, cid })`, not URI alone; `parseGenericRecordKey` accepts only those keys and throws on malformed ones. Unavailable records are `null`, and takedowns are excluded unless explicitly requested.\n- Preserve `truncated` from actor backlink lookups: an empty target entry in a truncated batch does not prove the actor has no links.\n- Saved-post hydration selects one CID-pinned root per embed, regardless of collection: the first authored ref, except a legacy publication-first pair may select a document at index 1. Never inspect later refs or fall back from an unavailable selected root. Batch selected roots across post layers through the generic exact-ref traversal. A document's publication is a discovered latest dependency, not a second authored pin; discarded refs must not affect rendering or moderation. A publication selected as the root remains exact.\n- Pass all external URIs to `Hydrator.hydrateExternalViewDependencies`; dispatch by URI collection and validate records inside it. Batch new dependencies through `hydrateEmbedExternalViewFromUris`, sharing a `seenRecordKeys` set (including null lookups) across nested passes. Follow only complete DID-based record URIs.\n- `hydrateEmbedExternalViewFromUris` nulls each generic record (in `externalRecords` and `externalRecordsByRef`, keeping keys) that has an actionable takedown label (unless `includeTakedowns`) or an explicitly `null` owner actor, before dependency expansion. Hiding is per record: never propagate it to targets or dependencies, and leave backlink counts and samples unadjusted.\n- Dependency expansion is bounded by collection-typed edges and per-source sample limits, not a page-wide allowance. Validate direct references and backlink results with `recordUriOf`: complete DID record URI, expected collection, no query/fragment. A source query must never admit a URI of another collection, which could turn a terminal preview into another expanding root. Keep dependencies batched/deduped across posts; direct-only passes issue no backlink or count calls.\n- External hydration traversals share a `seenRecordKeys` set that both wrappers filter against at entry by `genericRecordKey(ref)` (bare URI for latest, `uri@cid` for exact), so each lookup runs once per traversal (null results stay seen). `MAX_EXTERNAL_HYDRATION_PASSES` hard-caps chained batches (root is pass 1; `hydrationPass` is threaded through both methods and never reset). Physical request chunks within a batch consume no passes. At the cap, keep partial state, leave skipped URIs unseen, and warn.\n- External-view counts, backlink pages, and nested dependency batches are best-effort: log failures and leave their state absent (a successful empty page is `[]`), and builders consume partial state, so galleries may be empty. Root fetch errors still propagate. Each recovered failure sets the tracker's `partial` flag, so the traversal reports `partial` unless it was `capped`, which takes precedence. Don't add failure-policy machinery.\n- Thread the optional `hydrationMetrics` tracker through every nested external hydration call (wrappers and `hydrateExternalViewDependencies`). Only the call without one owns the traversal and reports `events.externalHydrationTraversal` once; omitting it in a nested call double-reports. Count lookups at batch scheduling, never from map or seen-set sizes, and keep totals out of metric attributes. `batches` counts logical passes, not RPCs. The reported endpoint is `HydrateCtx.xrpcEndpoint`, an NSID-formatted string that handlers set from their method's `$lxm`. Discovery counts each inspected ref as resolved if available after moderation; the first discovery call's refs are roots (tracked by a flag, not pass number), later ones dependencies.\n- `hydrateEmbedExternalViewFromUris` and `hydrateEmbedExternalViewFromRefs` are thin wrappers over one pipeline and one collection dispatch. Latest lookups are identified by URI (`externalRecords`); exact versions by `genericRecordKey({ uri, cid })` (`externalRecordsByRef`). Both identities share `seenRecordKeys`, and one never implies the other; a latest fetch does not mark its returned version as an exact lookup. Dependency discovery takes `ItemRef[]` and inspects every pinned version; nested dependencies are authored URIs and always go through the URI wrapper. A successful lookup with the same URI and CID may replace an unavailable pinned version; a different CID never satisfies it.\n- External hydration takes `ExternalHydrationOptions` (`hydration`, plus the shared `seenRecordKeys`, `hydrationPass`, `hydrationMetrics`). `'basic'` keeps renderable content (including gallery item samples and photos, publication discovery), owner profiles/labels/moderation and backlink counts, but skips social-preview samples and their records, participant profiles and labels. `'detailed'` (default) adds those previews. Collection rules in `discoverExternalViewDependencies` decide this, never callsites; counts are scheduled for every target even with no sample queries. One level applies to a whole traversal: pass it through every nested call. `hydratePosts` (feeds) uses basic. The level never changes identity, pin or latest-dependency semantics.\n\n# External embed validation utilities\n\n- `util/external/standard-site.ts` (`getStandardSiteRecordsForUri`, `validateStandardSiteRecordsForUrl`) and `util/external/index.ts` (`isExternalRecordUrlSafe`) are pure and synchronous over caller-supplied hydrated records, URLs, and a domain list: no I/O, config reads, mutation, or view construction.\n\n# `app.bsky.embed.getEmbedExternalView`\n\n- The handler normalizes the request to one record URI before hydration: an explicit `canonicalUri` always wins (ignoring `uris`, with no fallback); otherwise the legacy `uris` input selects the first `site.standard.document`, else the first `site.standard.publication`. Only the one URI is hydrated; dependencies come from existing hydration.\n- Validate before building any view: Standard.site records via `getStandardSiteRecordsForUri` + `validateStandardSiteRecordsForUrl`, other records via `isExternalRecordUrlSafe(url, cfg.externalRecordsSafeDomains)`. Renderability is never the trust test.\n- Validated records return `data` and `associatedRefs`/`associatedRecords`; failed validation returns only `unverifiedAssociatedRefs`/`unverifiedAssociatedRecords` and builds no view. The legacy `view` is returned for Standard.site records only. Both outputs and the legacy `view.external.associatedRefs` use the same index-aligned persisted subset, canonical first.\n- Render-time hydration and persistence are separate. Hydration may include engagement records (recommendations, subscriptions, favorites) that back `likers`/`subscribers` previews; that is intentional. `collectAssociations` persists the canonical record when available (whatever its collection) plus only the extra collections opted in by `PERSISTED_DEPENDENCY_COLLECTIONS` (document → publication; gallery → gallery item and photo), to keep post records slim. It is not an engagement blacklist. A new persisted dependency collection needs an explicit entry; do not change hydration or views for this.\n","isInternal":false,"tokens":2304,"sizeBytes":11256},{"name":"AGENTS.md","path":"packages/lex/lex-resolver/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-resolver/AGENTS.md","title":"Lex-resolver Skill","category":"anthropic-skill","format":"markdown","content":"# AGENTS.md\n\n## Lexicon codegen: exception to the per-package `lexicons.json` convention\n\nThis package does **not** follow the repo-wide lexicon-management convention (a\nper-package `lexicons.json` manifest resolved with `lex install`; see\n[CONTRIBUTING → \"Working with lexicons\"](../../../CONTRIBUTING.md#working-with-lexicons)\nand the `lex-setup` skill). It has no `lexicons.json` and compiles the canonical\nschemas straight from the repo-root [lexicons/](../../../lexicons/):\n\n```\n\"codegen:lex\": \"lex-builder build --lexicons ../../../lexicons --out ./src/lexicons --clear --lib @atproto/lex-schema --include com.atproto.sync.getRecord --include com.atproto.lexicon.schema\"\n```\n\n**Why:** the installer (`@atproto/lex-installer`) depends on `@atproto/lex-resolver`,\nso this package cannot depend on the installer to manage a manifest without\ncreating a dependency cycle. It only needs canonical (`com.atproto.*`) schemas,\nwhich live in the root `lexicons` directory anyway, so compiling them directly is\nsufficient.\n\n**Do not** add a `lexicons.json`, `@atproto/lex`, or `@atproto/lex-installer`\nhere, and do not \"migrate\" this `codegen:lex` to the `lex install` form — keep the\n`lex-builder build --lexicons ../../../lexicons --include …` shape. Only\n`/src/lexicons` is gitignored (generated); there is no resolved `./lexicons`\ndirectory to manage.\n","isInternal":false,"tokens":347,"sizeBytes":1357},{"name":"CLAUDE.md","path":"packages/oauth/oauth-provider-ui/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-provider-ui/CLAUDE.md","title":"Oauth-provider-ui Skill","category":"anthropic-skill","format":"markdown","content":"# oauth-provider-ui\n\nUI for the OAuth provider's authorization & account-management screens. React 19, TanStack Router, Tailwind 4, shadcn/ui on Base UI, native-constraint forms on `@base-ui/react/form`, Lingui i18n.\n\n## Imports\n\n**Import from `react` by name — `import { useState, type ComponentProps }`.**\nNever `import * as React`, and never reach for the `React.*` global without an\nimport at all (`@types/react` declares it, so `React.ComponentProps` compiles\nwith no import and silently introduces a third style).\n\nThe exception is `components/ui/*`, which is shadcn CLI output and stays\nbyte-faithful — the registry emits `import * as React`, and rewriting it would\nput a diff in every file on the next `shadcn add`. The boundary is the\ndirectory: inside it, whatever the registry emits; everywhere else, named\nimports.\n\n## Verification\n\nType-check from the package directory:\n\n```bash\ncd packages/oauth/oauth-provider-ui\npnpm exec tsc --build tsconfig.json\npnpm test\npnpm run build:ui\n```\n\n**Run eslint from the repo root, not the package.**\n\n```bash\ncd /path/to/atproto\npnpm exec eslint --fix 'packages/oauth/oauth-provider-ui/src/**/*.tsx'\n```\n\nPrettier's Tailwind plugin reorders class strings. Accept its order.\n\n## Looking at the UI\n\n`pnpm dev:ui` serves all four entry pages on port 5174 against `src/mock-api.ts`\n— no PDS, no docker, no dev-env. Prefer it over booting the dev environment.\n\nThe account page starts signed out. The account rows in the picker are not\n`<button>`s — click `[aria-label^=\"Sign in as\"]`. The router uses real paths, not\nhashes, so navigate by clicking sidebar links rather than setting\n`location.hash`.\n\nEvery path under `/account` serves the account page, as it does behind the PDS\n(the `mock-account-paths` plugin in `vite.config.js`), so deep links and\nrefreshes work. That is what makes the mock usable for anything to do with URL\nstate — `account-page.html` must therefore keep its imports absolute and leave\nthe path alone when it is already under `/account/`.\n\n## Routing\n\n**File-based.** `src/routes/` is the route tree, and `src/routeTree.gen.ts` is\ngenerated from it by `@tanstack/router-plugin` on every dev run and build. The\ngenerated file is committed; don't edit it, and don't add it to a lint or format\npass (it is already in `.prettierignore`). `autoCodeSplitting` gives every route\ncomponent its own chunk — no `lazyRouteComponent` by hand.\n\nNaming follows TanStack's conventions: `__root.tsx`, `index.tsx` for a path's\nown page, a `$` prefix for params, `route.tsx` for a directory's layout, and a\n`-` prefix for files that are colocated content rather than routes (the About\npage's localized copies live in `-about/`).\n\n**Guards go in `beforeLoad`, never in render.** `/account` decides where the\ndevice's sessions belong (welcome / picker / the selected account) and\n`/account/u/$accountId` resolves the account the URL names, each throwing\n`redirect()`. `beforeLoad` returns `{ session }`, so descendants read the\naccount from `Route.useRouteContext()` instead of resolving it again. Rendering\na `<Navigate>` to gate a page reintroduces a render-then-bounce and a window\nwhere children fetch as the wrong account.\n\n**Router context** carries what the guards and loaders need but can't reach\nthrough React: `auth` (the session store), `api`, and `queryClient`. It is\nsupplied at `<RouterProvider context={…}/>` in `account-page.tsx`.\n\nTwo consequences that are easy to get wrong:\n\n- `auth` is `SessionContextType.store`, whose fields are **getters**, not a\n  render snapshot. A guard runs during a navigation that is usually dispatched\n  from the same event handler that just mutated the session, so a captured\n  value would be one render stale and the guard would reject the account it was\n  just sent to.\n- Changing the context does not re-run the guards. `account-page.tsx`\n  invalidates the router when the sessions change, which is what makes signing\n  out move the user with no navigation wired into the action.\n\nData pages load through the route: `loader` calls\n`queryClient.ensureQueryData(…QueryOptions(api, …))` and the component reads the\nsame query with `useSuspenseQuery`, so there is no in-component loading state.\nFailures surface through the route's `errorComponent` (whose `reset` retries).\n\nThe third-party consent flow has no router at all — it is a linear wizard and\nkeeps `AuthenticationProvider`.\n\nA link's `to` is a path _template_ (`/account/u/$accountId/manage`) — it never\nequals `location.pathname`. Test the current page with `useMatchRoute`\n(`useIsCurrentTarget` in `AccountShell`) and mark active links with\n`activeOptions={{ exact: true }}`; without `exact`, the account home matches\nevery page below it.\n\n## Components\n\n- `components/ui/*` — shadcn primitives, style `base-nova`, on `@base-ui/react`.\n  **Generated by the shadcn CLI, not hand-written.** Regenerate into a scratch\n  project and copy across, applying only these rewrites: `@/lib/utils` →\n  `#/lib/utils.ts`, `@/registry/…/ui/x` → `#/components/ui/x.tsx`, and drop any\n  `'use client'` directive (a Next.js RSC marker; this app is Vite). Keep these\n  files byte-faithful to upstream so future `shadcn add` stays mergeable — put\n  deviations in wrapper components instead. Base UI composes via\n  `render={<El/>}`, not a `asChild`-style prop.\n  The registry's components assume `@custom-variant` bridges that ship in the\n  style's `globals.css`, so copying `components/ui/*` alone is not enough — see\n  the orientation bridge in `src/style.css`. `Separator` silently rendered at\n  zero width/height until it was added.\n  Every file in there is a real registry entry — if `shadcn add <name>` under\n  this style returns an item with no files, the component is ours and belongs\n  outside this directory.\n- `components/forms/*` — `FormShell` (the form frame: action row, submit/cancel/\n  back, root error), `fields/*` (one wrapper per input type), plus\n  `AsyncButton`, `CopyButton`, `RequestCodeButton`, `SignUpWizard`.\n- `components/dialogs/*` — `DialogShell` (frame).\n- `components/feedback/*` — `Notice`, `ErrorNotice`, `ErrorCard`,\n  `ErrorDetails`, `PasswordStrength`.\n- `components/identity/*` — account/client avatars, names, identifiers, the\n  account menu.\n- `components/layouts/*` — `AuthShell` (the centred auth card) and\n  `AccountShell` (sidebar + content). These two own the whole page frame,\n  including the `<title>`, so they must never nest.\n- Pages under `src/pages/` and the four `*-page.tsx` entry points own contexts,\n  TanStack Query and routing.\n\n## Forms\n\nForms are native: `FormShell` (on `@base-ui/react/form`) plus one field wrapper\nper input. Validation is HTML constraint attributes on the controls —\n`required`, `type`, `pattern`, `minLength` — so an invalid submit is blocked by\nthe browser with its own bubble, in the browser's locale. There is no form\nlibrary, no schema layer, and no store: the DOM is the source of truth, and\n`FormShell` reads the values off the element with `FormData`.\n\n```tsx\n<FormShell<Values> onSubmit={(values, signal) => handler(values, signal)}>\n  <EmailField name=\"email\" required label={<Trans>Email</Trans>} />\n</FormShell>\n```\n\nShared regexes for `pattern` attributes live in `src/lib/form-patterns.ts`.\nNote `pattern` sources are implicitly anchored — no `^`/`$` — and compiled with\nthe `v` flag, under which a literal `-` in a character class must be escaped\n(`[a-z0-9\\-]`). A pattern that fails to compile is silently ignored by the\nbrowser (Chrome logs \"Pattern attribute value … is not a valid regular\nexpression\"), disabling the constraint. The pds `oauth.test.ts` asserts\n`validity.patternMismatch` on the sign-in identifier to catch this in a real\nbrowser.\n\nBase UI rules this layer depends on — each one was a real, browser-only bug:\n\n- **Values must come from `FormData`, never from `onFormSubmit`'s argument.**\n  Base UI only passes values for controls registered through `Field.Root`; a\n  plain named input (a checkbox, a hidden input) is silently absent. `FormShell`\n  already does this — don't \"simplify\" it back.\n- **`Form` defaults to `noValidate`.** `FormShell` re-enables the browser's\n  gate; without it, any control outside a `Field.Root` is validated by no one.\n- **A `Field.Root` binds every descendant control to its own name**, even over\n  an explicit `name` prop. A field composed of two controls (`NewHandleField`)\n  must opt out of `Field` entirely.\n- **A `disabled` control is omitted from form values.** Use `readOnly` when the\n  value must still submit.\n- **`type=\"email\"` accepts `user@host` with no dot** — `EmailField` carries a\n  `pattern` requiring a TLD on top.\n\n`FormShell` submits through `useAsyncAction`, which handles abort-on-unmount,\nabort-on-supersede and error retention. Errors render via `ErrorNotice` +\n`apiErrorParser`, so typed OAuth error payloads keep their user-facing messages.\nFor callers that must survive remounting (the sign-up wizard's back/forward),\n`onValues` mirrors the form's values on every edit.\n\n**Reserve space for anything that appears conditionally** next to an\ninteractive control — a layout shift between mousedown and mouseup lands the\nclick on a different element and silently swallows it.\n\n### Field names are a public contract\n\nThe `name` attribute on each control is selected on by the pds e2e suite and by\npassword managers. These names are owned by this package and must not be\nrenamed: `identifier`, `password`, `remember`, `token`, `emailOtp`, `email`,\n`handle`, `domain`, `inviteCode`.\n\nControls are named after the API field they feed (`identifier`, `token`,\n`emailOtp`), so values pass through without remapping. Keep it that way when\nadding one. Identifier fields also keep `autoComplete=\"username\"`, which is\nwhat marks them for password managers.\n\n## i18n (Lingui)\n\n- `.po` files under `src/locales/*` are generated; don't hand-edit except to add\n  French translations.\n- After any `.tsx` edit run `pnpm i18n`, then **check the diff for added or\n  removed `msgid`s**. Only line-reference churn is expected. This check has\n  caught two bugs that nothing else would have.\n- msgids are the English source strings, so changing a string orphans five\n  locales. `<Trans>` placeholders are positional (`<0>`, `<1>`) and derive from\n  JSX nesting — adding or reordering an element inside a `<Trans>` renumbers them\n  and changes the msgid. Recompose _around_ `<Trans>` blocks. Expression\n  placeholders are named by the identifier — `{minLength}` and `{MIN_LENGTH}`\n  are different msgids — so alias a renamed variable back rather than touching\n  the string.\n- **Never pass `t` as a prop.** The macro only transforms `` t`…` `` in a scope\n  that imports `useLingui` from `@lingui/react/macro`; passing `t` down leaves\n  the template uncompiled and the string untranslated, and it silently vanishes\n  from the catalogs.\n- A msgid that disappears and returns loses its translation. After extraction,\n  check the _count_ of untranslated entries, not just the msgid diff.\n- Fill in French only; other locales are translated externally.\n\n## The pds e2e contract\n\n`packages/pds/tests/{oauth,account-manager}.test.ts` drive this UI by text and\ntag, so markup choices are load-bearing. The test files themselves are the\nauthoritative record of which text sits in which element. The rules:\n\n- actions the tests click must be real `<button>`s\n- body copy in `<p>`, page/section headings in the tag the test names\n  (`h2`/`h3`), identifiers and radio labels in `<span>`, code samples in `<code>`\n- dialog content keeps `role=\"dialog\"` and a `button[type=\"submit\"]`\n- `CardDescription` and `AlertDescription` render `<div>`, so wrap asserted copy\n  in an explicit `<p>`. **`DialogDescription` does not** — Base UI's\n  `Dialog.Description` is already a `<p>`, and wrapping it produced `<p><p>`,\n  which React reports as invalid nesting. Check the primitive's rendered element\n  before adding a wrapper rather than assuming they match.\n\nDialogs must cap their height (`DialogShell` does): Base UI's `DialogContent` is\n`fixed` with no height limit, and a tall dialog on a short viewport puts its\nbuttons out of reach with no way to scroll to them.\n\n## Branding\n\n`CustomizationData` supplies the logo, name, footer links and\n`availableUserDomains`. Colours arrive separately, at runtime: the provider\ninjects `--branding-color-*` variables (see\n`oauth-provider/src/customization/build-customization-css.ts`), and the\n\"Runtime branding\" section of `src/style.css` maps `primary`, `error`, `info`,\n`warning` and `success` onto the shadcn tokens. Each is one injected RGB that\nconsumers tint with opacity modifiers, not the old multi-shade hue palette.\nWhen a deployment configures nothing the tokens fall back to the pre-redesign\nstock palette (a purple primary) — deliberately not Bluesky's, so an\nuncurated PDS is not mistaken for one.\n\n`branding.background` customises the auth screen's page area (behind the card,\nnot the card itself). It carries a light and a dark image URL; the provider\ninjects `--branding-background-{light,dark}-image` (wrapped in `url(...)`), and\nthe `.auth-background` rule in `src/style.css` paints that image over the neutral\n`muted` base, picking the mode by `prefers-color-scheme`. Unset, it falls back to\nthe plain `muted` surface it always was. The card keeps its own opaque surface so\ncopy stays legible over any background.\n\nThe mock pages inject the same variables a branded deployment would, so\n`pnpm dev:ui` demonstrates branding without a PDS.\n\n## Utility types\n\n`Override<A, B>` (from `#/lib/util.ts`) is the standard prop-extension pattern:\n\n```tsx\ntype Props = Override<JSX.IntrinsicElements['button'], { icon: LucideIcon }>\n```\n\nIcons are `lucide-react`.\n\n## Linting quirks\n\nESLint requires unused vars/args to match `/^_/u`. Drop the destructure rather\nthan prefixing when the prop is genuinely unused.\n","isInternal":false,"tokens":3455,"sizeBytes":13904},{"name":"AGENTS.md","path":"packages/ozone/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/ozone/AGENTS.md","title":"Ozone Skill","category":"anthropic-skill","format":"markdown","content":"# Ozone moderation inbox\n\n- Reject wildcard `queryLabels` requests before accessing the DB unless `OZONE_LABEL_QUERY_WILDCARDS_ENABLED` is enabled; exact queries remain available.\n- Execute cancellable listing SQL with the HTTP signal and Kysely's `inflightQueryAbortStrategy: 'cancel query'`; a signal alone leaves PostgreSQL work running.\n- Reporter statistics seek recent non-appeal reports by reporter/time and look up distinct linked action IDs by primary key; never join reports to subject event history.\n\n- Read previews share `inboxViewerDid`; writes always use the authenticated DID.\n- Notification preference writes atomically patch only provided categories. Unset category columns inherit the retained push column to preserve existing opt-outs without a backfill; preferences do not suppress in-app inbox activity.\n- Produce moderation notifications from the common `ModerationService.logEvent` path so daemon actions are covered. Keep source snapshots in the source transaction, and isolate optional queries/inserts with `runNotificationWork` savepoints; a caught SQL error alone leaves PostgreSQL's transaction aborted.\n- Batch report notifications and skip non-transition activities before querying. Viewer responses never expose moderator note text.\n- Inbox appeal state and filters prioritize active reports; eligibility considers inbox appeal reports, including reopened and pre-cutoff history. Serialize inbox appeal submissions by subject; subject reviewState and appealed flags are not synchronized by report activities. Unread includes appeal report updates, independently of the requested sort field. Apply filters before pagination.\n- Appeal outcomes come from the current close activity and its linked corrective event, never merely nonempty actionEventIds (which also contain the appealed action). No-action appeal closures record denied in activity metadata; unknown historical closures omit the outcome. Resolution notifications snapshot the outcome.\n- Seek the latest appeal per requested subject with an indexed lateral LIMIT before joining its closure and resolution event; outcome work must be bounded by the page size, not appeal history.\n- Read `NEXT_PUBLIC_STRIKE_SUSPENSION_CONFIG` through configuration. The second and third sorted strike thresholds determine warning and atRisk; retain the full hours map, including Infinity.\n- Use explicit predicates for partial indexes on heavy tables. Report pagination seeks report.reporterDid/time indexes, validates source ownership before LIMIT by lateral primary-key lookup, and materializes only the page before hydration. Reporter backfill/catch-up is a deployment prerequisite.\n- OZONE_INBOX_START_AT bounds history by inclusive creation time before pagination/aggregation, including details and notification targets. Express the cutoff on the sort/seek index's own column (report updatedAt pages also bound `updatedAt`), join per-DID aggregates to statuses on indexed columns (`recordPath`), and bind the viewer DID as a constant in correlated `EXISTS` checks: PostgreSQL may hash such subplans and drop correlation. Keep live standing/enforcement and historical appeal eligibility authoritative; only display post-cutoff appeal history.\n- Migrations that use report.reporterDid must run after _20261002T000000000Z. Pre-create large indexes concurrently in production and verify they are valid and ready before running transactional migrations.\n","isInternal":false,"tokens":625,"sizeBytes":3428},{"name":"AGENTS.md","path":"packages/repo/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/repo/AGENTS.md","title":"Repo Skill","category":"anthropic-skill","format":"markdown","content":"# packages/repo\n\n## MST traversals MUST call `markVisited`\n\nEvery code path that can visit more than one child of an MST node — full-tree\nwalks, diffs, block collection; recursive or iterative; in `src/mst/mst.ts`,\n`walker.ts`, `diff.ts`, or new code anywhere — **MUST**:\n\n1. create a fresh `CidSet` per traversal (never share one between traversals),\n   and\n2. call `node.markVisited(seen)` on every `MST` node it enters, before reading\n   that node's entries (`getEntries`, `atIndex`, …).\n\nThis is a requirement, not an optimization: a traversal that skips it is a bug\neven if every test passes. When adding a traversal, add a matching case to\n`tests/mst-dag.test.ts`.\n\nExempt: lookups that follow a single path (one recursive call per level, e.g.\n`cidsForPath`, `proofForKey`), which are bounded by tree depth, and walks over\nin-memory nodes built by local mutations (outdated pointers, e.g. `serialize`),\nsince nodes loaded from storage never have one.\n\n`MST.markVisited` throws `VisitedCidError` on a repeated node and skips nodes\nwith an outdated pointer (not yet serialized).\n","isInternal":false,"tokens":276,"sizeBytes":1089},{"name":"CLAUDE.md","path":"packages/repo/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/repo/CLAUDE.md","title":"Repo Skill","category":"anthropic-skill","format":"markdown","content":"# CLAUDE.md\n\n@AGENTS.md\n","isInternal":false,"tokens":11,"sizeBytes":24},{"name":"README.md","path":"packages/api/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/api/README.md","title":"api Documentation","category":"plugin-manifest","format":"markdown","content":"# ATP API\n\nThis API is a client for ATProtocol servers. It communicates using HTTP. It includes:\n\n- ✔️ APIs for ATProto and Bluesky.\n- ✔️ Validation and complete typescript types.\n- ✔️ Session management.\n- ✔️ A RichText library.\n\n> [!Note]\n>\n> We recommend using [lex](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), our type-safe SDK with Lexicon code generation, for all new projects.\n\n## Getting started\n\nFirst install the package:\n\n```sh\nyarn add @atproto/api\n```\n\nThen in your application:\n\n```typescript\nimport { Agent, CredentialSession } from '@atproto/api'\n\nconst session = new CredentialSession(new URL('https://bsky.social'))\nawait session.login(account)\nconst agent = new Agent(session)\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 `Agent`\nclass.\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 { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api'\n\n// Configure connection to the server with authentification\nconst account: AtpAgentLoginOpts = {\n  identifier: 'your.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n}\n\nasync function authenticate(account: AtpAgentLoginOpts): Promise<Agent> {\n  const session = new CredentialSession(new URL('https://example.com'))\n  await session.login(account)\n  const agent = new Agent(session)\n  return agent\n}\n\n;(async () => {\n  console.log('Authenticating...')\n  const agent = await authenticate(account)\n  console.log(`Authenticated as from: ${agent.sessionManager.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\n`OAuthSession` instance that can be used to instantiate an `Agent` (from\n`@atproto/api`). Here is an example restoring a previously saved session:\n\n```typescript\nimport { Agent } from '@atproto/api'\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 api Agent using an OAuthSession\nconst agent = new Agent(oauthSession)\n```\n\n### API calls\n\nThe agent includes methods for many common operations, including:\n\n```typescript\n// The DID of the user currently authenticated (or undefined)\nagent.did\nagent.accountDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait agent.getTimeline(params, opts)\nawait agent.getAuthorFeed(params, opts)\nawait agent.getPostThread(params, opts)\nawait agent.getPost(params)\nawait agent.getPosts(params, opts)\nawait agent.getLikes(params, opts)\nawait agent.getRepostedBy(params, opts)\nawait agent.post(record)\nawait agent.deletePost(postUri)\nawait agent.like(uri, cid)\nawait agent.deleteLike(likeUri)\nawait agent.repost(uri, cid)\nawait agent.deleteRepost(repostUri)\nawait agent.uploadBlob(data, opts)\n\n// Social graph\nawait agent.getFollows(params, opts)\nawait agent.getFollowers(params, opts)\nawait agent.follow(did)\nawait agent.deleteFollow(followUri)\n\n// Actors\nawait agent.getProfile(params, opts)\nawait agent.upsertProfile(updateFn)\nawait agent.getProfiles(params, opts)\nawait agent.getSuggestions(params, opts)\nawait agent.searchActors(params, opts)\nawait agent.searchActorsTypeahead(params, opts)\nawait agent.mute(did)\nawait agent.unmute(did)\nawait agent.muteModList(listUri)\nawait agent.unmuteModList(listUri)\nawait agent.blockModList(listUri)\nawait agent.unblockModList(listUri)\n\n// Notifications\nawait agent.listNotifications(params, opts)\nawait agent.countUnreadNotifications(params, opts)\nawait agent.updateSeenNotifications()\n\n// Identity\nawait agent.resolveHandle(params, opts)\nawait agent.updateHandle(params, opts)\n\n// Legacy: Session management should be performed through the SessionManager\n// rather than the Agent instance.\nif (agent instanceof AtpAgent) {\n  // AtpAgent instances support using different sessions during their lifetime\n  await agent.createAccount({ ... }) // session a\n  await agent.login({ ... }) // session b\n  await agent.resumeSession(savedSession) // session c\n}\n```\n\n### Validation and types\n\nThe package includes a complete types system which includes validation and type-guards. For example, to validate a post record:\n\n```typescript\nimport { AppBskyFeedPost } from '@atproto/api'\n\nconst post = {...}\nif (AppBskyFeedPost.isRecord(post)) {\n  // typescript now recognizes `post` as a AppBskyFeedPost.Record\n  // however -- we still need to validate it\n  const res = AppBskyFeedPost.validateRecord(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 { RichText } from '@atproto/api'\n\n// creating richtext\nconst rt = new RichText({\n  text: 'Hello @alice.com, check out this link: https://example.com',\n})\nawait rt.detectFacets(agent) // automatically detects mentions and links\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: new Date().toISOString(),\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 { moderatePost } from '@atproto/api'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: agent.session.did,\n  moderationPrefs: 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 include in feeds\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 methods above are convenience wrappers. It covers most but not all available methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can use them on the agent as well:\n\n```typescript\nconst res1 = await agent.com.atproto.repo.createRecord({\n  did: alice.did,\n  collection: 'app.bsky.feed.post',\n  record: {\n    $type: 'app.bsky.feed.post',\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n})\nconst res2 = await agent.com.atproto.repo.listRecords({\n  repo: alice.did,\n  collection: 'app.bsky.feed.post',\n})\n\nconst res3 = await agent.app.bsky.feed.post.create(\n  { repo: alice.did },\n  {\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n)\nconst res4 = await agent.app.bsky.feed.post.list({ repo: alice.did })\n```\n\n### Non-browser configuration\n\nIf your environment doesn't have a built-in `fetch` implementation, you'll need\nto provide one. This will typically be done through a polyfill.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation, you can do so by\ninstantiating the sessionManager with a custom fetch implementation:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst myFetch = (input: RequestInfo | URL, init?: RequestInit) => {\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 agent = new AtpAgent({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\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":2725,"sizeBytes":11331},{"name":"README.md","path":"packages/aws/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/aws/README.md","title":"aws Documentation","category":"plugin-manifest","format":"markdown","content":"# AWS KMS\n\nA Keypair-compatible wrapper for AWS KMS.\n","isInternal":false,"tokens":16,"sizeBytes":53},{"name":"README.md","path":"packages/bsky/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/bsky/README.md","title":"bsky Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/bsky: Bluesky AppView Service\n\nTypeScript implementation of the `app.bsky` Lexicons backing the https://bsky.app microblogging application.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsky)](https://www.npmjs.com/package/@atproto/bsky)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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":261,"sizeBytes":1033},{"name":"README.md","path":"packages/bsky/src/feature-gates/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/bsky/src/feature-gates/README.md","title":"feature-gates Documentation","category":"plugin-manifest","format":"markdown","content":"# Feature Gates\n\nA thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag\nevaluation and experiment tracking.\n\n## Usage\n\nFeature gates are accessible on `HydrationCtx` as `features`. A default \"scope\"\nis defined in `Hydrator.createContext`, which is called by every request\nhandler. The default scope supplies anonymous `deviceId` and `sessionId`\nidentifiers for targeting unauthenticated users.\n\nFeature gates can be checked via the following methods.\n\n```typescript\n// check a single gate\nconst enabled = ctx.features.checkGate(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n\n// check multiple gates\nconst gates = ctx.features.checkGates([\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n  ctx.features.Gate.SomeOtherGate,\n])\nconst enabled = gates.get(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n```\n\n### User Context\n\nTo accurately gate features by user, we need additional context about that user.\nFor most use cases, we get this data from within the request handlers.\n\nHere, the `features` client will be scoped to the current user, allowing for\naccurate gate checks throughout the request lifecycle.\n\n```typescript\nconst features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(\n  ctx.featureGatesClient.parseUserContextFromHandler({\n    viewer,\n    req,\n  }),\n)\n\nconst hydrateCtx = await ctx.hydrator.createContext({\n  labelers,\n  viewer,\n  features,\n})\n```\n\n> [!NOTE]\n> Although `ctx.featureGatesClient` also has a `checkGates` method, the\n> intention is to use the `ScopedFeatureGatesClient` returned by `scope()` for\n> gate checks within the application.\n\nYou can also pass the user context directly to `checkGate` or `checkGates`. This\noverrides any default scope set on the client, allowing for flexibility in cases\nwhere user context is only available at the point of gate evaluation.\n\n```typescript\nconst enabled = ctx.featureGatesClient.checkGate(\n  ctx.featureGatesClient.Gate.ImageFeatureEnabled,\n  { did: imageAuthor.did },\n)\n```\n\n### Adding Gates\n\nSee `gates.ts` and add them to the enum. You can optionally prevent metrics from\nbeing emitted for a gate by adding the gate to the `IGNORE_METRICS_FOR_GATES`\nset.\n\n## Metrics\n\nCheck out the Growthbook docs for more info here. Basically, every feature eval\nfires the `onFeatureUsage` callback. Any feature that is part of an experiment\n_will also fire the `trackingCallback` callback._\n\nFor some use cases, this can be noisy and/or performance intensive, so make use\nof `IGNORE_METRICS_FOR_GATES` to silence metrics for specific gates.\n","isInternal":false,"tokens":588,"sizeBytes":2576},{"name":"README.md","path":"packages/bsync/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/bsync/README.md","title":"bsync Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/bsync: Synchronizing Service for the Bluesky AppView\n\nThis is an optional service that may be used to synchronize certain state between otherwise independent AppViews.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsync)](https://www.npmjs.com/package/@atproto/bsync)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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":261,"sizeBytes":1063},{"name":"README.md","path":"packages/common-web/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/common-web/README.md","title":"common-web Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/common-web\n\nShared TypeScript code for other `@atproto/*` packages, which is web-friendly (runs in-browser).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common-web)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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":249,"sizeBytes":1010},{"name":"README.md","path":"packages/common/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/common/README.md","title":"common Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/common\n\nShared TypeScript code for other `@atproto/*` packages. This package is oriented towards writing servers, and is not designed to be browser-compatible.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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":255,"sizeBytes":1057},{"name":"README.md","path":"packages/crypto/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/crypto/README.md","title":"crypto Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/crypto\n\nTypeScript library providing basic cryptographic helpers as needed in [atproto](https://atproto.com).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/crypto)](https://www.npmjs.com/package/@atproto/crypto)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\nThis package implements the two currently supported cryptographic systems:\n\n- P-256 elliptic curve: aka \"NIST P-256\", aka secp256r1 (note the r), aka prime256v1\n- K-256 elliptic curve: aka \"NIST K-256\", aka secp256k1 (note the k)\n\nThe details of cryptography in atproto are described in [the specification](https://atproto.com/specs/cryptography). This includes string encodings, validity of \"low-S\" signatures, byte representation \"compression\", hashing, and more.\n\n## Usage\n\n```typescript\nimport { verifySignature, Secp256k1Keypair, P256Keypair } from '@atproto/crypto'\n\n// generate a new random K-256 private key\nconst keypair = await Secp256k1Keypair.create({ exportable: true })\n\n// sign binary data, resulting signature bytes.\n// SHA-256 hash of data is what actually gets signed.\n// signature output is often base64-encoded.\nconst data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])\nconst sig = await keypair.sign(data)\n\n// serialize the public key as a did:key string, which includes key type metadata\nconst pubDidKey = keypair.did()\nconsole.log(pubDidKey)\n\n// output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38'\n\n// verify signature using public key\nconst ok = verifySignature(pubDidKey, data, sig)\nif (!ok) {\n  throw new Error('Uh oh, something is fishy')\n} else {\n  console.log('Success')\n}\n```\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":626,"sizeBytes":2352},{"name":"README.md","path":"packages/dev-env/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/dev-env/README.md","title":"dev-env Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/dev-env: Local Developer Environment\n\nA command-line application for developers to construct and manage development environments.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/dev-env)](https://www.npmjs.com/package/@atproto/dev-env)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\n## REPL API\n\nThe following methods are available in the REPL.\n\n### `status()`\n\nList the currently active servers.\n\n### `startPds(port?: number)`\n\nCreate a new PDS instance. Data is stored in memory.\n\n### `stop(port: number)`\n\nStop the server at the given port.\n\n### `mkuser(handle: string, pdsPort?: number)`\n\nCreate a new user.\n\n### `user(handle: string): ServiceClient`\n\nGet the `ServiceClient` for the given user.\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":349,"sizeBytes":1447},{"name":"README.md","path":"packages/dev-infra/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/dev-infra/README.md","title":"dev-infra Documentation","category":"plugin-manifest","format":"markdown","content":"# dev-infra\n\nHelpers for working with postgres and redis locally. Previously known as `pg`.\n\n## Usage\n\n### `with-test-db.sh`\n\nThis script allows you to run any command with a fresh, ephemeral/single-use postgres database available. When the script starts a Dockerized postgres container starts-up, and when the script completes that container is removed.\n\nThe environment variable `DB_POSTGRES_URL` will be set with a connection string that can be used to connect to the database. The [`PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) that are recognized by libpq (i.e. used by the `psql` client) are also set.\n\n**Example**\n\n```\n$ ./with-test-db.sh psql -c 'select 1;'\n[+] Running 1/1\n ⠿ Container pg-db_test-1  Healthy                                                           1.8s\n\n ?column?\n----------\n        1\n(1 row)\n\n\n[+] Running 1/1\n ⠿ Container pg-db_test-1  Stopped                                                           0.1s\nGoing to remove pg-db_test-1\n[+] Running 1/0\n ⠿ Container pg-db_test-1  Removed\n```\n\n### `with-redis-and-test-db.sh`\n\nThis script is similar to `with-test-db.sh`, but in addition to an ephemeral/single-use postgres database it also provides a single-use redis instance. When the script starts, Dockerized postgres and redis containers start-up, and when the script completes the containers are removed.\n\nThe environment variables `DB_POSTGRES_URL` and `REDIS_HOST` will be set with a connection strings that can be used to connect to postgres and redis respectively.\n\n### `docker-compose.yaml`\n\nThe Docker compose file can be used to run containerized versions of postgres either for single use (as is used by `with-test-db.sh`), or for longer-term use. These are setup as separate services named `db_test` and `db` respectively. In both cases the database is available on the host machine's `localhost` and credentials are:\n\n- Username: pg\n- Password: password\n\nHowever, each service uses a different port, documented below, to avoid conflicts.\n\n#### `db_test` service for single use\n\nThe single-use `db_test` service does not have any persistent storage. When the container is removed, data in the database disappears with it.\n\nThis service runs on port `5433`.\n\n```\n$ docker compose up db_test   # start container\n$ docker compose stop db_test # stop container\n$ docker compose rm db_test   # remove container\n```\n\n#### `db` service for persistent use\n\nThe `db` service has persistent storage on the host machine managed by Docker under a volume named `pg_atp_db`. When the container is removed, data in the database will remain on the host machine. In order to start fresh, you would need to remove the volume.\n\nThis service runs on port `5432`.\n\n```\n$ docker compose up db -d    # start container\n$ docker compose stop db     # stop container\n$ docker compose rm db       # remove container\n$ docker volume rm pg_atp_db # remove volume\n```\n\n#### `redis_test` service for single use\n\nThe single-use `redis_test` service does not have any persistent storage. When the container is removed, the data in redis disappears with it.\n\nThis service runs on port `6380`.\n\n#### `redis` service for persistent use\n\nThe `redis` service has persistent storage on the host machine managed by Docker under a volume named `atp_redis`. When the container is removed, the data in redis will remain on the host machine. In order to start fresh, you would need to remove the volume.\n\nThis service runs on port `6379`.\n","isInternal":false,"tokens":788,"sizeBytes":3486},{"name":"README.md","path":"packages/identity/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/identity/README.md","title":"identity Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/identity\n\nTypeScript library for decentralized identities in [atproto](https://atproto.com) using DIDs and handles\n\n[![NPM](https://img.shields.io/npm/v/@atproto/identity)](https://www.npmjs.com/package/@atproto/identity)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\n\n## Example\n\nResolving a Handle and verifying against DID document:\n\n```typescript\nconst didres = new DidResolver({})\nconst hdlres = new HandleResolver({})\n\nconst handle = 'atproto.com'\nconst did = await hdlres.resolve(handle)\n\nif (did == undefined) {\n  throw new Error('expected handle to resolve')\n}\nconsole.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz\n\nconst doc = await didres.resolve(did)\nconsole.log(doc)\n\n// additional resolutions of same DID will be cached for some time, unless forceRefresh flag is used\nconst doc2 = await didres.resolve(did, true)\n\n// helper methods use the same cache\nconst data = await didres.resolveAtprotoData(did)\n\nif (data.handle != handle) {\n  throw new Error('invalid handle (did not match DID document)')\n}\n```\n\n## SSRF protection\n\nHTTP identity resolution — the handle `/.well-known/atproto-did` endpoint,\n`did:plc`, and `did:web` — is routed through an SSRF-protected fetch by\ndefault. Private IPs, IP-literal hosts, `http:` URLs, custom ports, and a\nhandful of forbidden domain names are refused.\n\nTo resolve identities against localhost or another private network — a test\nsuite or a local dev stack — pass your own `fetch`. A supplied `fetch` is\nused as-is and is never re-wrapped:\n\n```typescript\nconst didres = new IdResolver({ plcUrl, fetch: globalThis.fetch })\n```\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":580,"sizeBytes":2333},{"name":"README.md","path":"packages/internal/fetch-node/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/internal/fetch-node/README.md","title":"fetch-node Documentation","category":"plugin-manifest","format":"markdown","content":"# Node.js fetch utilities\n\nFetch helpers for Node.js 22+ that help protect services when fetching URLs supplied by users.\n\n## Usage\n\nCreate a fetch wrapper once and reuse it:\n\n```js\nimport { safeFetchWrap } from '@atproto-labs/fetch-node'\n\nconst safeFetch = safeFetchWrap()\nconst response = await safeFetch('https://api.github.com/repos/nodejs/node', {\n  redirect: 'follow',\n})\nconst data = await response.json()\n```\n\nBy default, `safeFetchWrap` allows HTTPS on port 443, blocks private and reserved IP addresses and a list of forbidden domains, sets a 10-second timeout, and limits response bodies to 512 KiB. Destination checks also apply to redirects, and IP addresses are checked when connecting.\n\nPass `redirect` explicitly on each call: use `'follow'`, `'manual'`, or `'error'`. Configure the wrapper with options such as `timeout` (milliseconds), `responseMaxSize` (bytes), and `forbiddenDomainNames`. If you supply a custom `fetch`, it must honor Undici's `dispatcher` option; do not pass a separate dispatcher on individual calls.\n\n## Other helpers\n\n- `unicastFetchWrap()` restricts destinations to public IP addresses, including redirects, without adding the HTTPS, timeout, or response-size rules.\n- `safeDispatchFetchWrap({ checkUrl, lookup })` lets you supply your own destination checks. `checkUrl` receives the origin of each request, including redirects, and returns a reason string to block it or `undefined` to allow it.\n- `unicastLookup` checks DNS results before connecting. `isUnicastIpHostname(hostname)` checks a literal IP address and returns `undefined` for a domain name.\n- This package also exports the helpers from `@atproto-labs/fetch` for working with requests, responses, errors, and fetch wrappers.\n\n## Temporary eMudhra TLS mitigation\n\n`emudhra-preload` blocks HTTPS connections that use any of the eight intermediate keys affected by the [eMudhra compromise](https://emudhra.we-have-a-problem.com/), as listed in [nodejs/node#66871](https://github.com/nodejs/node/pull/66871). It keeps the existing trusted roots, so other intermediates under those roots still work. Remove it once you deploy Node.js with the fix.\n\nNode supports multiple `--import` flags. Load this before tracing and application code:\n\n```sh\nnode --import=@atproto-labs/fetch-node/emudhra-preload --import=@atproto/pds/telemetry service.js\n```\n\nOr copy [`src/emudhra-preload.mjs`](src/emudhra-preload.mjs) into your image and load it directly. It needs no other files or packages:\n\n```sh\nnode --import=/opt/mitigations/emudhra-preload.mjs --import=./tracing.mjs service.js\n```\n\nYou can also add `--import=/opt/mitigations/emudhra-preload.mjs` to `NODE_OPTIONS`. Restart every process and discard any TLS sessions saved before the preload was installed. Resumed sessions skip the check.\n\nThe preload works with Node's HTTPS client, built-in `fetch()`, and Undici's standard connectors, including custom CAs and hostname checks. It wraps `tls.connect()` and adds the key check to the client's existing certificate check. Blocked connections fail with `CERT_REVOKED` before any HTTP request data is sent.\n\nIt checks keys during full TLS handshakes and caches up to 256 certificate results to avoid repeated work. Normal certificate validation still runs. Reusing a connection or resuming a checked session adds no key-check work.\n\nLimits:\n\n- Keep certificate verification enabled: do not set `rejectUnauthorized: false` or `NODE_TLS_REJECT_UNAUTHORIZED=0`.\n- Clients must use `tls.connect()` after the preload runs. Other TLS libraries, direct `TLSSocket` use, and incoming client certificates are not covered. `--require` runs before `--import`, so avoid opening connections or saving a reference to `tls.connect` there.\n- Workers and forked processes normally inherit the preload. Keep it enabled if you override `execArgv` or `NODE_OPTIONS`.\n- A server can also be blocked if it sends an unused certificate with one of the eight keys. Unlike Node's native fix, this preload cannot check only the certificate path OpenSSL chose.\n","isInternal":false,"tokens":916,"sizeBytes":4031},{"name":"README.md","path":"packages/internal/handle-resolver/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/internal/handle-resolver/README.md","title":"handle-resolver Documentation","category":"plugin-manifest","format":"markdown","content":"# Universal Handle Resolver implementation for ATPROTO\n\nThis package provides a handle resolver implementation for ATPROTO. It is used\nto resolve handles to their corresponding DID.\n\nThis package is meant to be used in any JavaScript environment that support the\n`fetch()` function. Because APTORO handle resolution requires DNS resolution,\nyou will need to provide your own DNS resolution function when using this\npackage.\n\nThere are two main classes in this package:\n\n- `AtprotoHandleResolver` This implements the official ATPROTO handle resolution\n  algorithm (and requires a DNS resolver).\n- `AppViewHandleResolver` This uses HTTP requests to the Bluesky AppView\n  (bsky.app) to provide handle resolution.\n\n## Usage\n\n### From a front-end app\n\nSince the ATPROTO handle resolution algorithm requires DNS resolution, and the\nbrowser does not provide a built-in DNS resolver, this package offers two\noptions:\n\n- Delegate handle resolution to an AppView (`AppViewHandleResolver`). This is\n  the recommended approach for front-end apps.\n- Use a DNS-over-HTTPS (DoH) server (`DohHandleResolver`). Prefer this method\n  if you don't own an AppView and already have a DoH server that you trust.\n\nUsing an AppView:\n\n> [!CAUTION]\n> Use the Bluesky owned AppView (`https://api.bsky.app/`), or PDS\n> (`https://bsky.social/`), at your own risk. Using these servers in a\n> third-party application might expose your users' data (IP address) to Bluesky.\n> Bluesky might log the data sent to it when your app is resolving handles.\n> Bluesky might also change the API, or terms or use, at any time without\n> notice. Make sure you are compliant with the Bluesky terms of use as well as\n> any laws and regulations that apply to your use case.\n\n```ts\nimport { AppViewHandleResolver } from '@atproto-labs/handle-resolver'\n\nconst resolver = new AppViewHandleResolver({\n  service: 'https://my-app-view.com/',\n})\nconst did = await resolver.resolve('my-handle.bsky.social')\n```\n\nUsing DNS-over-HTTPS (DoH) for DNS resolution:\n\n> [!CAUTION]\n> Using a DoH server that you don't own might expose your users' data to\n> the DoH server provider. The DoH server provider might log the data sent to it\n> by your app, allowing them to track which handles are being resolved by your\n> users. In the browser, it is recommended to use a DoH server that you own and\n> control. Or to implement your own AppView and use the `AppViewHandleResolver`\n> class.\n\n> [!NOTE]\n> Using the `DohHandleResolver` requires a DNS-over-HTTPS server that\n> supports the DNS-over-HTTPS protocol with \"application/dns-json\" responses.\n\n```ts\nimport { DohHandleResolver } from '@atproto-labs/handle-resolver'\n\n// Also works with 'https://cloudflare-dns.com/dns-query'\nconst resolver = new DohHandleResolver('https://dns.google/resolve', {\n  // Optional: Custom fetch function that will be used both for DNS resolution\n  // and well-known resolution.\n  fetch: globalThis.fetch.bind(globalThis),\n})\n\nconst did = await resolver.resolve('my-handle.bsky.social')\n```\n\n### From a Node.js app\n\n> [!NOTE]\n> On a Node.js backend, you will probably want to use the\n> \"@atproto-labs/handle-resolver-node\" package. The example below applies to\n> Node.js code running on a user's machine (e.g. through Electron).\n\n```ts\nimport { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'\nimport { resolveTxt } from 'node:dns/promises'\n\nconst resolver = new AtprotoHandleResolver({\n  // Optional: Custom fetch function (used for well-known resolution)\n  fetch: globalThis.fetch.bind(globalThis),\n\n  resolveTxt: async (domain: string) =>\n    resolveTxt(domain).then((chunks) => chunks.join('')),\n})\n```\n\n### Caching\n\nUsing a default, in-memory cache, in which items expire after 10 minutes:\n\n```ts\nimport {\n  AppViewHandleResolver,\n  CachedHandleResolver,\n  HandleResolver,\n  HandleCache,\n} from '@atproto-labs/handle-resolver'\n\n// See previous examples for creating a resolver\ndeclare const sourceResolver: HandleResolver\n\nconst resolver = new CachedHandleResolver(sourceResolver)\nconst did = await resolver.resolve('my-handle.bsky.social')\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\n```\n\nUsing a custom cache:\n\n```ts\nimport {\n  AppViewHandleResolver,\n  CachedHandleResolver,\n  HandleResolver,\n  HandleCache,\n} from '@atproto-labs/handle-resolver'\n\n// See previous examples for creating a resolver\ndeclare const sourceResolver: HandleResolver\n\nconst cache: HandleCache = {\n  set(handle, did): Promise<void> {\n    /* TODO */\n  },\n  get(handle): Promise<undefined | string> {\n    /* TODO */\n  },\n  del(handle): Promise<void> {\n    /* TODO */\n  },\n}\n\nconst resolver = new CachedHandleResolver(sourceResolver, cache)\nconst did = await resolver.resolve('my-handle.bsky.social')\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\nconst did = await resolver.resolve('my-handle.bsky.social') // Result from cache\n```\n\n### Observing resolution errors\n\nBy default, `WellKnownHandleResolver` (used internally by `AtprotoHandleResolver`\nand `AtprotoHandleResolverNode`) returns `null` for non-abort errors,\nindistinguishable from \"no `.well-known/atproto-did` endpoint exists\". That is\ncorrect for the resolver contract, but it hides operational signals such as 5xx\nupstream errors, SSRF blocks, redirect loops, or transient network failures.\n\nTo log or surface those failures, pass an `onError` handler when constructing the\nresolver:\n\n```ts\nconst resolver = new AtprotoHandleResolverNode({\n  onError: (err, { resolver, handle }) => {\n    console.warn(\n      `[handle-resolver:${resolver}] ${handle} failed to resolve:`,\n      err,\n    )\n  },\n})\n```\n\nBecause the handler is set on the instance, it also fires for resolution done on\nyour behalf, which a per-call option cannot reach. For example, an OAuth client\nyou hand the resolver to:\n\n```ts\nconst client = new NodeOAuthClient({\n  handleResolver: new AtprotoHandleResolverNode({ onError: logHandleError }),\n  // ...\n})\n```\n\nThe handler is observational only: the resolver still returns `null` on failure,\nso existing behavior does not change. Use it to:\n\n- Log resolution failures for debugging or alerting.\n- Surface specific failures to end users (e.g. distinguishing a misconfigured\n  custom domain from a typo'd handle).\n- Emit telemetry for handle-host operators.\n","isInternal":false,"tokens":1495,"sizeBytes":6389},{"name":"README.md","path":"packages/internal/opentelemetry-node/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/internal/opentelemetry-node/README.md","title":"opentelemetry-node Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto-labs/opentelemetry-node\n\nOpenTelemetry bootstrap for atproto Node.js services.\n\nThis package wires up the OpenTelemetry Node SDK from the conventional `OTEL_*`\nenvironment variables, so a service only has to supply its identity and the list\nof instrumentations it wants.\n\nTelemetry is **opt-in via the exporter endpoint**: the SDK stays disabled until an\nOTLP endpoint is configured (`OTEL_EXPORTER_OTLP_ENDPOINT` or a signal-specific\nvariant). `OTEL_SDK_DISABLED` remains a kill switch.\n\n## Configuration\n\nEverything beyond the service identity and instrumentation list is driven by the\nconventional `OTEL_*` environment variables. See the OpenTelemetry docs for the\nfull set:\n\n- [General SDK configuration](https://opentelemetry.io/docs/languages/sdk-configuration/general/)\n- [OTLP exporter configuration](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/)\n\n## Usage\n\nLoad it before any instrumented module — typically through Node's `--import` flag:\n\n```ts\nimport { setup } from '@atproto-labs/opentelemetry-node'\nimport { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from '@atproto-labs/opentelemetry-node/conventions'\nimport { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'\nimport pkg from './package.json' with { type: 'json' }\n\nawait setup(() => ({\n  name: pkg.name,\n  version: pkg.version,\n  // Optional: override or extend the default resource attributes.\n  defaultResourceAttributes: { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: 'staging' },\n  // Service-specific instrumentations. The instrumentations common to atproto\n  // services are always registered in addition to these.\n  instrumentations: [new IORedisInstrumentation()],\n}))\n```\n\n`setup()` takes a thunk so nothing is constructed when telemetry is disabled: the\noptions — and therefore the instrumentations — are only built once an OTLP endpoint\nis configured.\n\n### XRPC routes on metrics\n\nPass the known XRPC methods the service may answer (including the ones it\nproxies) as `xrpcMethods`. This allows to set the `http.route` attribute\ncorrectly for these methods in the metrics. When `xrpcMethods` is omitted, the\n`http.route` of the metrics is left as set by the Express instrumentation.\n\n```ts\n// @NOTE **do not** import runtime code here (like `ids` from\n// `@atproto/lex-cli`'s `./lexicon/lexicons.js`) as telemetry setup must run\n// before any instrumented module. Importing from the `@atproto/lex` manifest\n// (JSON) is fine.\n\nimport { setup } from '@atproto-labs/opentelemetry-node'\nimport pkg from './package.json' with { type: 'json' }\nimport lex from './lexicons.json' with { type: 'json' }\n\nawait setup(() => ({\n  name: pkg.name,\n  version: pkg.version,\n  xrpcMethods: lex.lexicons\n    // @NOTE we only care about methods, which, by convention, have a verb\n    // followed by a capitalized noun (e.g., `getFeed`). Reducing the list\n    // helps keep the attribute low-cardinality.\n    .filter((v) => /[A-Z]/.test(v)),\n}))\n```\n\nThis sets the `http.route` attribute of the HTTP server duration metric to\n`/xrpc/<nsid>` for these methods, and to `/xrpc/{unknown}` for any other NSID.\nThe list is what keeps the attribute low-cardinality: any client\ncan make up an NSID. Span names aren't affected.\n\nInstrumentations you supply must be built on the same\n`@opentelemetry/instrumentation` as this package, or ESM imports of the modules\nthey target can't be instrumented: `setup()` then rejects with an\n`InstrumentationMismatchError`, failing the service start. For `better-sqlite3`, use the\n`BetterSqlite3Instrumentation` exposed by the\n`@atproto-labs/opentelemetry-node/better-sqlite3-plugin` entrypoint: a port of\n[`opentelemetry-plugin-better-sqlite3`](https://github.com/sjinks/opentelemetry-plugin-better-sqlite3)\nthat satisfies this requirement.\n\nThe `@atproto-labs/opentelemetry-node/conventions` entrypoint re-exports\n`@opentelemetry/semantic-conventions` plus the atproto-specific constants: the\nXRPC attribute keys (`ATTR_XRPC_METHOD`, `ATTR_XRPC_PROXIED`,\n`ATTR_XRPC_PROXY`), the bsync stash attribute keys (`ATTR_BSYNC_NAMESPACE`,\n`ATTR_BSYNC_OPERATION`), and `RPC_CALL_DURATION_BUCKETS`. The\n`@atproto-labs/opentelemetry-node/util` entrypoint exposes\n`statusCodeToString()`, which renders a Connect status as the snake_case string\n`rpc.response.status_code` expects.\n\nBoth sides of an RPC need to agree on these, so they live here rather than in\neither service — otherwise the caller and the callee end up depending on each\nother just to label a metric.\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\nBluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).\n","isInternal":false,"tokens":1287,"sizeBytes":5283},{"name":"README.md","path":"packages/internal/rolldown-plugin-bundle-manifest/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/internal/rolldown-plugin-bundle-manifest/README.md","title":"rolldown-plugin-bundle-manifest Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto-labs/rolldown-plugin-bundle-manifest\n\nThis Rolldown plugin allows to generate a (JSON) manifest containing the output\nfiles of a Rolldown build. The manifest will look as follows:\n\n```json\n{\n  \"main.js\": {\n    \"type\": \"chunk\",\n    \"mime\": \"application/javascript\",\n    \"dynamicImports\": [],\n    \"isDynamicEntry\": false,\n    \"isEntry\": true,\n    \"name\": \"main\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  },\n  \"main.js.map\": {\n    \"type\": \"asset\",\n    \"mime\": \"application/json\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  },\n  \"main.css\": {\n    \"type\": \"asset\",\n    \"mime\": \"text/css\",\n    \"sha256\": \"<sha256-hash>\",\n    \"data\": \"<base64-encoded-contents>\"\n  }\n  // ... more entries as needed\n}\n```\n\nThis manifest will typically be useful for a backend service that serves the\nfrontend assets, as it can be used to determine the correct `Content-Type` and\nand file integrity (via the SHA-256 hash), without having to read the files\nthemselves.\n\n## Usage\n\n```js\n// rolldown.config.js\n\nimport { bundleManifest } from '@atproto-labs/rolldown-plugin-bundle-manifest'\n\nexport default {\n  input: 'src/index.js',\n  output: {\n    dir: 'dist',\n    format: 'es',\n  },\n  plugins: [\n    bundleManifest({\n      name: 'bundle-manifest.json',\n\n      // Optional: should the asset data be embedded (as base64 string) in the manifest?\n      data: false,\n    }),\n  ],\n}\n```\n\n## Options\n\n- `name` (string): The name of the manifest file. Defaults to `bundle-manifest.json`.\n- `data` (boolean): Whether to embed the asset data in the manifest. Defaults to `false`.\n\n## Example\n\n```js\nconst assetManifest = require('./dist/bundle-manifest.json')\n\nconst app = express()\n\napp.use((req, res, next) => {\n  const asset = assetManifest[req.path.slice(1)]\n  if (!asset) return next()\n\n  res.setHeader('Content-Type', asset.mime)\n  res.setHeader('Content-Length', asset.data.length)\n\n  res.end(Buffer.from(asset.data, 'base64'))\n})\n\napp.use((req, res, next) => {\n  res.setHeader(\n    'Content-Security-Policy',\n    buildCSP(assetManifest), // Not provided here\n  )\n\n  // Serve the index.html file\n  res.sendFile('index.html')\n})\n```\n\n## License\n\nMIT\n","isInternal":false,"tokens":587,"sizeBytes":2192},{"name":"README.md","path":"packages/lex-cli/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex-cli/README.md","title":"lex-cli Documentation","category":"plugin-manifest","format":"markdown","content":"# Lexicon CLI Tool\n\nCommand-line tool to generate Lexicon schemas and APIs.\n\n## Usage\n\n```\nUsage: lex [options] [command]\n\nLexicon CLI\n\nOptions:\n  -V, --version                     output the version number\n  -h, --help                        display help for command\n\nCommands:\n  new [options] <nsid> [outfile]    Create a new schema json file\n  gen-md <schemas...>               Generate markdown documentation\n  gen-ts-obj <schemas...>           Generate a TS file that exports an array of schemas\n  gen-api <outdir> <schemas...>     Generate a TS client API\n  gen-server <outdir> <schemas...>  Generate a TS server API\n  help [command]                    display help for command\n```\n\n**Example 1:** Generate an api\n\n```\n$ lex gen-api ./api/src ./schemas/com/service/*.json ./schemas/com/another/*.json\n```\n\n**Example 2:** Generate a server\n\n```\n$ lex gen-server ./server/src/xrpc ./schemas/com/service/*.json ./schemas/com/another/*.json\n```\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":376,"sizeBytes":1556},{"name":"README.md","path":"packages/lex/lex-builder/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-builder/README.md","title":"lex-builder Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-builder\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nGenerates TypeScript schemas from [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex build` command.\n\n```typescript\nimport { build } from '@atproto/lex-builder'\n\nawait build({ lexicons: './lexicons', out: './src/lexicons' })\n```\n\nMost projects should use the `lex build` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.\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":305,"sizeBytes":1227},{"name":"README.md","path":"packages/lex/lex-cbor/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-cbor/README.md","title":"lex-cbor Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-cbor\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nCBOR ([DRISL](https://dasl.ing/drisl.html)) encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), as used for [repository](https://atproto.com/specs/repository) storage and signing.\n\n```typescript\nimport { decode, encode, cidForLex } from '@atproto/lex-cbor'\n\nconst bytes = encode(someLexValue)\nconst value = decode(bytes)\nconst cid = await cidForLex(someLexValue)\n```\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family. Unlike the JSON codec, this package is _not_ re-exported by `@atproto/lex` and must be installed directly.\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":361,"sizeBytes":1402},{"name":"README.md","path":"packages/lex/lex-client/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-client/README.md","title":"lex-client Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-client\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n- [`@atproto/lex-rpc` low-level XRPC utilities](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex-rpc/README.md)\n\nType-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) client for Lexicon-defined APIs: the `Client` class for working with a user's PDS (records) in the context of an authenticated session.\n\nAuthentication is supplied through an `Agent`, implemented by [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) or, for scripts and bots, [`@atproto/lex-password-session`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-password-session).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it -- prefer depending on that package.\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":392,"sizeBytes":1592},{"name":"README.md","path":"packages/lex/lex-data/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-data/README.md","title":"lex-data Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-data\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nCore types and utilities for the AT Protocol [data model](https://atproto.com/specs/data-model): `LexValue` and friends, CIDs, bytes, [blob](https://atproto.com/specs/blob) references, and string-length helpers.\n\nThe data model extends JSON with two additional structures — CIDs (content-addressed links) and raw bytes — which are encoded as JSON by [`@atproto/lex-json`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-json) and as CBOR by [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.\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":377,"sizeBytes":1513},{"name":"README.md","path":"packages/lex/lex-document/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-document/README.md","title":"lex-document Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-document\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nValidation of Lexicon documents themselves — the JSON files written in the [Lexicon schema definition language](https://atproto.com/specs/lexicon).\n\nWhere [`@atproto/lex-schema`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-schema) validates _data_ against a schema, this package validates the _schema documents_, and turns them into runtime schemas by resolving refs across documents.\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`lex-builder`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-builder), [`lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) and [`lex-resolver`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-resolver).\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":386,"sizeBytes":1604},{"name":"README.md","path":"packages/lex/lex-installer/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-installer/README.md","title":"lex-installer Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-installer\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nPackage manager for [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex install` command: it resolves Lexicons by [NSID](https://atproto.com/specs/nsid), writes them to a local directory, and tracks their versions (CIDs) in a `lexicons.json` manifest.\n\n```typescript\nimport { install } from '@atproto/lex-installer'\n\nawait install({\n  lexicons: './lexicons',\n  manifest: './lexicons.json',\n  add: ['app.bsky.feed.post'],\n  save: true,\n})\n```\n\nMost projects should use the `lex install` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.\n\n## Resolution sources\n\nEach Lexicon is resolved from, in order of priority:\n\n1. **An explicit `at://` URI** passed as an addition — fetched (and verified) from that repo.\n2. **An explicit local file** passed as an addition — any value that is a `file://` URI or\n   contains a slash (`./app.bsky.feed.post.json`, `file://../shared/my.json`) is read from disk\n   (relative to the cwd); its NSID is taken from the document's `id`.\n3. **The manifest's `resolvers`** — an ordered list of local override strategies consulted for\n   every dependency NSID before the network. Currently only `directory` is supported:\n\n   ```jsonc\n   {\n     \"version\": 1,\n     \"lexicons\": [\"com.example.foo\"],\n     \"resolvers\": [\n       {\n         \"type\": \"directory\",\n         \"path\": \"../../lexicons\", // relative to this lexicons.json\n         \"include\": [\"com.example.*\"], // optional NSID globs (default: all)\n         \"exclude\": [], // optional NSID globs this resolver never answers for\n       },\n     ],\n     \"resolutions\": {\n       \"com.example.foo\": {\n         \"uri\": \"file://../../lexicons/com/example/foo.json\",\n         \"cid\": \"...\",\n       },\n     },\n   }\n   ```\n\n   Resolvers are priority-ordered (first match wins) and are **not** reordered by manifest\n   normalization. A `directory` resolver maps an NSID to `<path>/<a>/<b>/<c>.json`.\n\n4. **The network** — the always-last fallback: NSID → DNS `_lexicon.<authority>` → DID → signed\n   `com.atproto.lexicon.schema` record.\n\n> [!NOTE]\n> A future `{ \"type\": \"repo\", ... }` resolver (resolving from specific DIDs rather than DNS\n> discovery) is planned but not yet implemented.\n\n### Symlinks\n\nLexicons resolved from a **local file** (sources 2 and 3 above) are **symlinked** into the output\n`lexicons/` directory rather than copied, so edits to the source file are reflected without\nreinstalling. The symlink is written relative to its destination for portability. As an exception,\nif the resolved destination path equals the source path, the file is left untouched. Their manifest\nlock `uri` is a `file://` path relative to the manifest, keeping it portable across checkouts.\nNetwork-resolved Lexicons are written as plain JSON files and locked with their `at://` URI.\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":912,"sizeBytes":3666},{"name":"README.md","path":"packages/lex/lex-json/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-json/README.md","title":"lex-json Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-json\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nJSON encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), including the `$link` (CID) and `$bytes` representations used by [XRPC](https://atproto.com/specs/xrpc).\n\nProvides `lexParse` / `lexStringify` to go between JSON strings and data-model values, and `jsonToLex` / `lexToJson` to convert already-parsed JSON objects. For the CBOR codec used by repository storage, see [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).\n\nThis is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.\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":366,"sizeBytes":1460},{"name":"README.md","path":"packages/lex/lex-password-session/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-password-session/README.md","title":"lex-password-session Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-password-session\n\nPassword-based session authentication for AT Protocol Lexicons. See the [Changelog](./CHANGELOG.md) for version history.\n\n```bash\nnpm install @atproto/lex-password-session\n```\n\n- Session management with automatic token refresh\n- Hooks for persisting and monitoring session state\n- PDS endpoint discovery from DID documents\n- Two-factor authentication support\n\n> [!IMPORTANT]\n>\n> This package is currently in **preview**. The API and features are subject to change before the stable release.\n\n**What is this?**\n\n`@atproto/lex-password-session` provides a `PasswordSession` class that implements the `Agent` interface from `@atproto/lex-client`. It handles password-based authentication with AT Protocol services, including:\n\n1. Creating sessions with username/password credentials\n2. Automatic token refresh when access tokens expire\n3. Session persistence through lifecycle hooks\n4. Graceful logout with server-side session cleanup\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport * as app from './lexicons/app.js'\n\n// Login with credentials\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\n\nconst client = new Client(session)\n\n// Make authenticated requests\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: session.did,\n})\n```\n\n<!-- START doctoc generated TOC please keep comment here to allow auto update -->\n<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->\n\n- [Quick Start](#quick-start)\n- [PasswordSession](#passwordsession)\n  - [Login](#login)\n  - [Two-Factor Authentication](#two-factor-authentication)\n  - [Resume Session](#resume-session)\n  - [Logout](#logout)\n  - [Static Delete](#static-delete)\n  - [Create Account](#create-account)\n- [Session Hooks](#session-hooks)\n  - [onUpdated](#onupdated)\n  - [onUpdateFailure](#onupdatefailure)\n  - [onDeleted](#ondeleted)\n  - [onDeleteFailure](#ondeletefailure)\n- [Session Data](#session-data)\n- [Error Handling](#error-handling)\n- [Using with Client](#using-with-client)\n- [License](#license)\n\n<!-- END doctoc generated TOC please keep comment here to allow auto update -->\n\n## Quick Start\n\n**1. Install the package**\n\n```bash\nnpm install @atproto/lex-password-session @atproto/lex-client\n```\n\n**2. Login and make requests**\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your-handle.bsky.social',\n  password: 'your-app-password',\n})\n\nconst client = new Client(session)\n\n// Make authenticated API calls\nconsole.log('Logged in as:', session.did)\n```\n\n## PasswordSession\n\nThe `PasswordSession` class manages password-based authentication sessions.\n\n### Login\n\nCreate a new session with username and password:\n\n```typescript\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social', // handle or email\n  password: 'app-password',\n  onUpdated: (data) => {\n    // Persist session for later restoration\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: () => {\n    localStorage.removeItem('session')\n  },\n})\n\nconsole.log('Logged in as:', session.did)\n```\n\nThe `login()` method throws on failure. For expected errors like invalid credentials, an `XrpcResponseError` is thrown. For 2FA requirements, a `LexAuthFactorError` is thrown.\n\n### Two-Factor Authentication\n\n> [!CAUTION]\n>\n> Two-factor authentication only applies when using **main account credentials**, which is **strongly discouraged**. Password authentication should be used with [app passwords](https://bsky.app/settings/app-passwords) only because they are designed for programmatic access (bots, scripts, CLI tools). For user-facing applications, use OAuth via [@atproto/oauth-client](../../../oauth/oauth-client) which provides better security and user control.\n\nIf the account has 2FA enabled, login will throw a `LexAuthFactorError`:\n\n```typescript\nimport {\n  PasswordSession,\n  LexAuthFactorError,\n} from '@atproto/lex-password-session'\n\nasync function loginWith2FA(\n  identifier: string,\n  password: string,\n  authFactorToken?: string,\n): Promise<PasswordSession> {\n  try {\n    return await PasswordSession.login({\n      service: 'https://bsky.social',\n      identifier,\n      password,\n      authFactorToken,\n      onUpdated: (data) => saveToStorage(data),\n      onDeleted: (data) => removeFromStorage(data.did),\n    })\n  } catch (err) {\n    if (err instanceof LexAuthFactorError && !authFactorToken) {\n      // 2FA required - prompt user for code\n      const token = await promptUserFor2FACode(err.message)\n      return loginWith2FA(identifier, password, token)\n    }\n    throw err\n  }\n}\n```\n\n### Resume Session\n\nRestore a previously saved session:\n\n```typescript\nimport { PasswordSession, SessionData } from '@atproto/lex-password-session'\n\n// Load session from storage\nconst savedSession: SessionData = JSON.parse(localStorage.getItem('session')!)\n\n// Resume the session (automatically refreshes tokens)\nconst session = await PasswordSession.resume(savedSession, {\n  onUpdated: (data) => {\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: () => {\n    localStorage.removeItem('session')\n  },\n})\n\nconsole.log('Session resumed for:', session.did)\n\n// Access session properties\nconsole.log(session.did) // User's DID\nconsole.log(session.handle) // User's handle\nconsole.log(session.destroyed) // false (session is active)\n```\n\n> [!NOTE]\n>\n> `resume()` automatically calls `refresh()` to ensure the session is valid and tokens are current.\n\n### Logout\n\nEnd the session and notify the server:\n\n```typescript\nawait session.logout()\n```\n\nAfter logout:\n\n- The `onDeleted` hook is called\n- The session is marked as destroyed (`session.destroyed === true`)\n- Further requests will throw `'Logged out'`\n\n### Static Delete\n\nDelete a session without creating a session instance:\n\n```typescript\nimport { PasswordSession, SessionData } from '@atproto/lex-password-session'\n\nconst data: SessionData = JSON.parse(localStorage.getItem('session')!)\n\n// Delete the session on the server\nawait PasswordSession.delete(data)\n```\n\nThis is useful for cleanup scenarios where you don't need to make additional requests.\n\n### Create Account\n\nCreate a new account and get an authenticated session:\n\n```typescript\nimport { PasswordSession } from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.createAccount(\n  {\n    handle: 'alice.bsky.social',\n    email: 'alice@example.com',\n    password: 'secure-password',\n  },\n  {\n    service: 'https://bsky.social',\n    onUpdated: (data) => saveToStorage(data),\n    onDeleted: (data) => removeFromStorage(data.did),\n  },\n)\n\nconsole.log('Account created:', session.did)\n```\n\n## Session Hooks\n\nHooks provide callbacks for session lifecycle events. All hooks receive the session instance as `this` context.\n\n### onUpdated\n\nCalled when the session is successfully created or refreshed:\n\n```typescript\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n  onUpdated(data) {\n    // `this` is the PasswordSession instance\n    console.log('Session updated for:', this.did)\n\n    // Persist the updated session\n    saveSession(data)\n  },\n})\n```\n\n> [!IMPORTANT]\n>\n> Requests are blocked while `onUpdated` is running. Keep this callback fast to avoid delays.\n\n### onUpdateFailure\n\nCalled when token refresh fails due to transient errors (network issues, server unavailability):\n\n```typescript\n{\n  onUpdateFailure(data, error) {\n    console.warn('Token refresh failed:', error.message)\n    // Session may still be valid - consider retry logic\n  }\n}\n```\n\n### onDeleted\n\nCalled when the session is terminated (logout or server-side invalidation):\n\n```typescript\n{\n  onDeleted(data) {\n    console.log('Session ended for:', data.did)\n    clearPersistedSession(data.did)\n    redirectToLogin()\n  }\n}\n```\n\n### onDeleteFailure\n\nCalled when logout fails due to transient errors:\n\n```typescript\n{\n  onDeleteFailure(data, error) {\n    console.error('Logout failed:', error.message)\n    // Consider queuing for retry to avoid orphaned sessions\n    queueLogoutRetry(data)\n  }\n}\n```\n\n> [!WARNING]\n>\n> Ignoring delete failures can leave sessions active on the server. Implement retry logic for security-sensitive applications.\n\n## Session Data\n\nThe `SessionData` type contains all data needed to authenticate and restore sessions:\n\n```typescript\ntype SessionData = {\n  // Session credentials and user info from createSession response\n  accessJwt: string\n  refreshJwt: string\n  did: string\n  handle: string\n  email?: string\n  emailConfirmed?: boolean\n  didDoc?: object\n  // ... other fields from createSession\n\n  // Original service URL used for login\n  service: string\n}\n```\n\n## Error Handling\n\nThe `PasswordSession` class uses exception-based error handling:\n\n```typescript\nimport {\n  PasswordSession,\n  LexAuthFactorError,\n} from '@atproto/lex-password-session'\nimport { XrpcResponseError } from '@atproto/lex-client'\n\ntry {\n  const session = await PasswordSession.login({\n    service: 'https://bsky.social',\n    identifier: 'alice.bsky.social',\n    password: 'wrong-password',\n  })\n} catch (err) {\n  if (err instanceof LexAuthFactorError) {\n    console.error('2FA required')\n  } else if (err instanceof XrpcResponseError) {\n    switch (err.error) {\n      case 'AuthenticationRequired':\n        console.error('Invalid credentials')\n        break\n      case 'AccountTakedown':\n        console.error('Account has been suspended')\n        break\n      default:\n        console.error('Login failed:', err.message)\n    }\n  } else {\n    throw err\n  }\n}\n```\n\nCommon error codes:\n\n| Error Code                | Description                    |\n| ------------------------- | ------------------------------ |\n| `AuthenticationRequired`  | Invalid username or password   |\n| `AuthFactorTokenRequired` | 2FA code needed                |\n| `AccountTakedown`         | Account suspended              |\n| `ExpiredToken`            | Token has expired (on refresh) |\n| `InvalidToken`            | Token is invalid               |\n\n## Using with Client\n\nThe `PasswordSession` implements the `Agent` interface and can be used directly with `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex-client'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport * as app from './lexicons/app.js'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'app-password',\n})\n\nconst client = new Client(session)\n\n// The client automatically uses the session for authentication\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: client.assertDid,\n})\n\n// Tokens are automatically refreshed when expired\nconst timeline = await client.call(app.bsky.feed.getTimeline, {\n  limit: 50,\n})\n\n// Create records\nawait client.create(app.bsky.feed.post, {\n  text: 'Hello from lex-password-session!',\n  createdAt: new Date().toISOString(),\n})\n```\n\nThe session handles:\n\n- Adding `Authorization` headers to requests\n- Detecting expired tokens (401 responses or `ExpiredToken` errors)\n- Automatically refreshing tokens and retrying failed requests\n- Routing requests to the correct PDS based on DID document\n\n## License\n\nMIT or Apache2\n","isInternal":false,"tokens":2628,"sizeBytes":11714},{"name":"README.md","path":"packages/lex/lex-resolver/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-resolver/README.md","title":"lex-resolver Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-resolver\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nResolves [Lexicon](https://atproto.com/specs/lexicon) documents from the AT Protocol network by [NSID](https://atproto.com/specs/nsid).\n\n```typescript\nimport { LexResolver } from '@atproto/lex-resolver'\n\nconst resolver = new LexResolver({})\nconst { lexicon, uri, cid } = await resolver.get('app.bsky.feed.post')\n```\n\nResolution looks up the `_lexicon.<authority>` DNS TXT record to find the controlling [DID](https://atproto.com/specs/did), resolves that DID to a PDS, then fetches and cryptographically verifies the Lexicon record. Hooks allow caching at the fetch layer.\n\nPart of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`@atproto/lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) to implement `lex install`.\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":404,"sizeBytes":1606},{"name":"README.md","path":"packages/lex/lex-rpc/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-rpc/README.md","title":"lex-rpc Documentation","category":"plugin-manifest","format":"markdown","content":"# @atproto/lex-rpc\n\n- [Changelog](./CHANGELOG.md)\n- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)\n\nType-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) utilities for Lexicon-defined APIs: exposes low-level `xrpc()` / `xrpcSafe()` functions for making requests based on Lexicon derived schemas.\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":241,"sizeBytes":971},{"name":"README.md","path":"packages/lex/lex-schema/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-schema/README.md","title":"lex-schema Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex-schema%2FREADME.md -->","isInternal":false,"tokens":322,"sizeBytes":1335},{"name":"README.md","path":"packages/lex/lex-server/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex-server/README.md","title":"lex-server Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex-server%2FREADME.md -->","isInternal":false,"tokens":3715,"sizeBytes":15551},{"name":"README.md","path":"packages/lex/lex/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lex/lex/README.md","title":"lex Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flex%2Flex%2FREADME.md -->","isInternal":false,"tokens":12100,"sizeBytes":50080},{"name":"README.md","path":"packages/lexicon-resolver/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lexicon-resolver/README.md","title":"lexicon-resolver Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flexicon-resolver%2FREADME.md -->","isInternal":false,"tokens":864,"sizeBytes":3211},{"name":"README.md","path":"packages/lexicon/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/lexicon/README.md","title":"lexicon Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Flexicon%2FREADME.md -->","isInternal":false,"tokens":393,"sizeBytes":1561},{"name":"README.md","path":"packages/oauth/oauth-client-browser/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-client-browser/README.md","title":"oauth-client-browser Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-browser%2FREADME.md -->","isInternal":false,"tokens":2873,"sizeBytes":12253},{"name":"README.md","path":"packages/oauth/oauth-client-browser/src/indexed-db/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-client-browser/src/indexed-db/README.md","title":"indexed-db Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-browser%2Fsrc%2Findexed-db%2FREADME.md -->","isInternal":false,"tokens":83,"sizeBytes":362},{"name":"README.md","path":"packages/oauth/oauth-client-expo/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-client-expo/README.md","title":"oauth-client-expo Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-expo%2FREADME.md -->","isInternal":false,"tokens":1299,"sizeBytes":5214},{"name":"README.md","path":"packages/oauth/oauth-client-node/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-client-node/README.md","title":"oauth-client-node Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client-node%2FREADME.md -->","isInternal":false,"tokens":3426,"sizeBytes":14533},{"name":"README.md","path":"packages/oauth/oauth-client/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-client/README.md","title":"oauth-client Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-client%2FREADME.md -->","isInternal":false,"tokens":2895,"sizeBytes":12282},{"name":"README.md","path":"packages/oauth/oauth-provider-ui/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-provider-ui/README.md","title":"oauth-provider-ui Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider-ui%2FREADME.md -->","isInternal":false,"tokens":317,"sizeBytes":1171},{"name":"README.md","path":"packages/oauth/oauth-provider/src/lib/html/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-provider/src/lib/html/README.md","title":"html Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider%2Fsrc%2Flib%2Fhtml%2FREADME.md -->","isInternal":false,"tokens":107,"sizeBytes":492},{"name":"README.md","path":"packages/oauth/oauth-provider/src/lib/http/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-provider/src/lib/http/README.md","title":"http Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-provider%2Fsrc%2Flib%2Fhttp%2FREADME.md -->","isInternal":false,"tokens":133,"sizeBytes":627},{"name":"README.md","path":"packages/oauth/oauth-types/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/oauth/oauth-types/README.md","title":"oauth-types Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Foauth%2Foauth-types%2FREADME.md -->","isInternal":false,"tokens":20,"sizeBytes":112},{"name":"README.md","path":"packages/ozone/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/ozone/README.md","title":"ozone Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fozone%2FREADME.md -->","isInternal":false,"tokens":1464,"sizeBytes":7110},{"name":"README.md","path":"packages/pds/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/pds/README.md","title":"pds Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fpds%2FREADME.md -->","isInternal":false,"tokens":291,"sizeBytes":1174},{"name":"README.md","path":"packages/pds/src/scripts/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/pds/src/scripts/README.md","title":"scripts Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fpds%2Fsrc%2Fscripts%2FREADME.md -->","isInternal":false,"tokens":639,"sizeBytes":2794},{"name":"README.md","path":"packages/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/README.md","title":"packages Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2FREADME.md -->","isInternal":false,"tokens":1552,"sizeBytes":6341},{"name":"README.md","path":"packages/repo/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/repo/README.md","title":"repo Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Frepo%2FREADME.md -->","isInternal":false,"tokens":306,"sizeBytes":1315},{"name":"README.md","path":"packages/sync/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/sync/README.md","title":"sync Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fsync%2FREADME.md -->","isInternal":false,"tokens":1036,"sizeBytes":4391},{"name":"README.md","path":"packages/syntax/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/syntax/README.md","title":"syntax Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fsyntax%2FREADME.md -->","isInternal":false,"tokens":692,"sizeBytes":2599},{"name":"README.md","path":"packages/tap/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/tap/README.md","title":"tap Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Ftap%2FREADME.md -->","isInternal":false,"tokens":1890,"sizeBytes":7655},{"name":"README.md","path":"packages/ws-client/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/ws-client/README.md","title":"ws-client Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fws-client%2FREADME.md -->","isInternal":false,"tokens":3487,"sizeBytes":15508},{"name":"README.md","path":"packages/xrpc-server/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/xrpc-server/README.md","title":"xrpc-server Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fxrpc-server%2FREADME.md -->","isInternal":false,"tokens":503,"sizeBytes":2001},{"name":"README.md","path":"packages/xrpc/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/atproto/HEAD/packages/xrpc/README.md","title":"xrpc Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/bluesky-social/atproto?path=packages%2Fxrpc%2FREADME.md -->","isInternal":false,"tokens":814,"sizeBytes":3207}],"systemPromptSnippet":"<yakaai_skills repo=\"bluesky-social/atproto\">\n<!-- File: AGENTS.md (Tokens: ~3482 | Category: root-instruction) -->\n# AGENTS.md\n\n## Repository overview\n\nThis is the TypeScript reference implementation of [AT Protocol](https://atproto.com), the decentralized social media protocol behind Bluesky. It is a pnpm monorepo (runtime floor Node.js ≥22) containing client libraries, schema/codegen tooling, and the two main service implementations: the Personal Data Server (PDS) and the `app.bsky` AppView.\n\nWorkspace layout (see [pnpm-workspace.yaml](./pnpm-workspace.yaml) and [tsconfig.json](./tsconfig.json)):\n\n- [packages/\\*](packages/) — top-level libraries: `api`, `common`, `crypto`, `identity`, `lexicon`, `repo`, `syntax`, `xrpc`, `xrpc-server`, `pds`, `bsky`, `bsync`, `ozone`, `dev-env`, `dev-infra`, etc.\n- [packages/lex/\\*](packages/lex/) — the modern type-safe Lexicon SDK family (`@atproto/lex`, `lex-builder`, `lex-cbor`, `lex-client`, `lex-data`, `lex-document`, `lex-json`, `lex-resolver`, `lex-server`, `lex-schema`, `lex-installer`, `lex-password-session`). New service code should use this in preference to the older `@atproto/api` / `@atproto/lexicon` / `@atproto/xrpc` / `@atproto/lex-cli` stack — see the `lex-*` skills listed under [Codegen](#codegen).\n- [packages/oauth/\\*](packages/oauth/) — OAuth client/provider implementations and JWK helpers.\n- [packages/internal/\\*](packages/internal/) — `@atproto-labs/*` internal shared utilities (fetch, handle/identity/DID resolvers, simple-store, pipe, xrpc-utils).\n- [services/{pds,bsky,bsync,ozone}](services/) — thin runtime wrappers; the actual implementation code lives in `packages/{pds,bsky,bsync,ozone}`.\n- [lexicons/](lexicons/) — the canonical JSON Lexicon schemas for `com.atproto.*` and `tools.ozone.*` **only** (this repo is their source of truth). Other namespaces (`app.bsky.*`, `chat.bsky.*`, …) are vendored per-package from the `bsky` repo or the network — see [Codegen](#codegen). [lexicons-overrides/](lexicons-overrides/) holds local override documents shared across packages.\n- [interop-test-files/](interop-test-files/) — language-neutral protocol conformance fixtures, copied from [bluesky-social/atproto-interop-tests](https://github.com/bluesky-social/atproto-interop-tests/) (their canonical source, shared across SDKs). Don't edit unless changing protocol-level behavior; any edit must also be contributed upstream.\n\n## Common commands\n\nWhole-repo verification commands (from root):\n\n```bash\n# build:tooling → prebuild (codegen) → build:ts → build:ui\npnpm run build [--force]\n\n# TypeScript-only build & typecheck\npnpm run build:ts [--force]\n\n# force code generation (lexicons, protobuf, i18n, utils, etc.)\npnpm run codegen\n\n# style + lint\npnpm run verify\npnpm run style:fix # Avoid, prefer per-file formatting & linting\n\n# lint specific files only\npnpm exec eslint --fix <path>\n\n# format specific files only\npnpm exec prettier --write <path>\n```\n\nPer-package work — **always run from inside the package directory**, not from the root:\n\n```bash\ncd packages/<pkg>\npnpm run build\npnpm run test\n```\n\nEvery package ships a `tsconfig.build.json` (composite, with explicit `references` to its workspace deps), and nearly every package with tests adds a `tsconfig.test.json` for the test sources. A package with root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, `lingui.config.ts`, `bin.js`, …) also adds a `tsconfig.config.json` so those files are type-checked — it extends [tsconfig/config.tsconfig.json](tsconfig/config.tsconfig.json) and `include`s the package's own `./*.{ts,js,cjs,mjs}`. When you add such a file to a package that has none, create `tsconfig.config.json` and reference it from the package `tsconfig.json` (`exclude` any root file that pulls `./src` or `./tests` into the program, e.g. a `jest.setup.ts`). The root `tsconfig.json` is a project-graph aggregator only.\n\nAvoid `pnpm run style:fix` (whole-repo prettier) unless the user explicitly asks for a repo-wide formatting pass.\n\nRun the formatter/linter once the work is complete: when about to commit, or when the user says the change is done, not after every small edit.\n\n## Tests\n\nFor Ozone integration tests, verify state through existing endpoints (for example, `queryEvents` for appeal events and `queryReports` for report state). Use direct database access only for fixtures that cannot be created through an API, or for tests specifically exercising database behavior.\n\nBefore writing or extending any test, invoke the `testing` skill ([.agents/skills/testing/SKILL.md](.agents/skills/testing/SKILL.md)). It covers runner selection (vitest vs jest), file layout, and tsconfig setup. For browser-driven UI tests, or for demoing/debugging the OAuth flows or the Account Manager interface, invoke the `playwright` skill ([.agents/skills/playwright/SKILL.md](.agents/skills/playwright/SKILL.md)) instead.\n\n## Codegen\n\nThe repo-root [lexicons/](lexicons/) holds the canonical `com.atproto.*` / `tools.ozone.*` schemas **only** — this repo is their source of truth. Every other namespace (`app.bsky.*`, `chat.bsky.*`, `site.standard.*`, …) is vendored **per package**: each package lists the NSIDs it needs and where to resolve them (the root `lexicons` directory, the `bsky` GitHub repo, the network, or the shared [lexicons-overrides/](lexicons-overrides/)) in its own committed `lexicons.json`. A package's `prebuild` runs `lex install --ci` (resolving JSON into its gitignored `./lexicons`) then `lex build` (generating the gitignored `./src/lexicons` TypeScript tree that code imports). **Full how-to: the [\"Working with lexicons\"](CONTRIBUTING.md#working-with-lexicons) section of CONTRIBUTING.md and the `lex-setup` skill.**\n\n- **Add/refresh an external lexicon in a package:** `cd packages/<pkg> && pnpm lex:install <nsid>` (adds it plus transitive deps); `pnpm lex:update` refreshes installed ones.\n- **Edit a canonical lexicon:** change it under [lexicons/](lexicons/), then run `pnpm lex:update-local` from the repo root to propagate the new CID into every dependent package's `lexicons.json`. (After editing a `.proto` file instead, run `pnpm codegen` from the root.)\n\nFor working with the generated SDK, invoke the focused skills under [.agents/skills/](.agents/skills/): `lex-setup` (lexicons.json / resolvers / codegen wiring), `lex-schema` and `lex-data` (schemas and values), `lex-client` (calls out), `xrpc-server` (defining server routes), and `lexification-client` / `lexification-server` (migrating off the legacy stack). To sync `chat.bsky.*` schemas from the chat repo, use `update-chat-lexicons`.\n\n## Architecture notes\n\n- **Lexicons are the contract.** Lexicon schemas drive both client types and server route validation. Service packages don't hand-write XRPC method signatures — they import the generated definitions from their `src/lexicons/` directory (gitignored; regenerated from the package's `lexicons.json` — see [Codegen](#codegen)).\n- ([packages/pds](packages/pds)) — a single-tenant atproto server: account management, repo storage (kysely-over-sqlite), actor storage (kysely-over-postgres), email, OAuth provider, blob storage. Runtime entry point is [services/pds](services/pds); production code is in `packages/pds/src`.\n  - Outbound requests to a URL resolved from a DID document are untrusted. Make them with `ctx.safeClient(url).xrpc(…)`, never a bare `xrpc(url, …)`, which uses the global fetch and bypasses the https-only/unicast-only checks. (`ctx.proxyAgent` is a separate thing — an undici `Dispatcher` used by pipethrough.)\n- ([packages/repo](packages/repo)) — **Every** MST traversal that can visit more than one child of a node (full-tree walks, diffs, block collection) MUST call `node.markVisited(seen)` on each node it enters, with a fresh `CidSet` per traversal. Never add one that skips it. Single-path lookups are exempt. See [packages/repo/AGENTS.md](packages/repo/AGENTS.md).\n- ([packages/bsky](packages/bsky)) — read-side service for `app.bsky.*` queries (timelines, profiles, feed generators, hydration pipeline, GraphQL-like view composition). Talks to PDSes via XRPC and to `bsync` via Connect-RPC (protobuf in `packages/bsky/proto`). Runtime entry point in [services/bsky](services/bsky).\n  - `app.bsky.feed.getFeed` forwards passthrough headers to a feed generator only when its record's service DID is in `bskyFeedgenDids`; Iris routing overrides do not grant permission.\n- ([packages/bsync](packages/bsync)) — internal service for cross-AppView synchronization (mutes, notifications). Connect-RPC interface.\n- ([packages/ozone](packages/ozone)) — moderation service for `tools.ozone.*`.\n  - Design viewer queries for hundreds of millions of moderation events and tens of millions of reports. Batch hydration, bound returned rows, and check query plans against existing indexes before proposing new ones. Keep viewer projections explicit and test ownership and private-field isolation through the API.\n  - Large report-table backfills run in the daemon as bounded primary-key batches. Lock a `job_cursor` row and commit each batch; schema migrations should add nullable columns only. After legacy report writers have rolled off, run `UPDATE job_cursor SET cursor = NULL WHERE job = 'report_reporter_did_backfill'` for a catch-up sweep. Advance past rows with missing or invalid source events and log their counts so they cannot stall the worker.\n- ([packages/dev-env](packages/dev-env)) — boots a full PDS + AppView + bsync + plc + ozone constellation in-process for tests and the `make run-dev-env` REPL. Most integration tests in `pds`/`bsky`/`ozone` use it as a fixture builder.\n\n## Conventions\n\n**Code style rules live in [STYLE_GUIDE.md](./STYLE_GUIDE.md)** — imports, typing, dependencies, change scope, and formatting. Read it before writing code. The rest of this section covers repository mechanics only.\n\n- Node ≥22 runtime floor; local nvm users default to Node 24 (`.nvmrc`). CI changeset/build/verify jobs read `devEngines.runtime.version` from `package.json`; tests stay on Node 22 for compatibility, with a security-patched minimum in `.github/workflows/repo.yaml`. Use `node --enable-source-maps` for production-style runs.\n- TypeScript compilation uses the native TS7 `tsc` (the standard `typescript` package). There is no per-package `typescript` devDependency — it is hoisted at the root. Note TS7 has no stable programmatic API yet; tools needing one must pin TS6.\n- **Every package touched by a change needs a changeset entry.** Add a file under [.changeset/](.changeset/) listing each modified package with an appropriate bump level (pre-v1 breaking changes are `minor` and everything else `patch`, post-v1 `major` for breaking changes, `minor` for new public API, `patch` otherwise). Dependency-only bumps are generated automatically — don't list them by hand. Create that file with `pnpm changeset`.\n- **Never buffer an unbounded stream from outside the process.** Decoding or buffering a request/response body that did not originate locally requires an explicit size bound on the _decoded_ bytes — a wire-size cap does not bound a compressed payload. Pass `maxSize` (and a `maxSizeError` for the caller's error type) to `createDecoders` and `pipeline` the result — it bounds every decoding stage, and must be used even when there is no content-encoding (see `packages/xrpc-server/src/util.ts`) — rather than passing a decoded stream straight to `streamToNodeBuffer`.\n\n## Agent files\n\nAgent files — this `AGENTS.md`, the skills under [.agents/skills/](.agents/skills/), and any package-level equivalents — are part of the codebase and must stay in sync with it.\n\n- Keep working plans, handoffs, review reports, query-plan captures, and other session scratch files outside the repository, such as in the workspace directory containing the checkout. Do not add repository ignore rules for them. Never commit them. Before committing, inspect the staged file list for working documents; put only durable project guidance in tracked documentation.\n\n- These files capture **important architectural patterns and conventions** — the non-obvious rules, invariants, and cross-cutting decisions an expert needs that are _not_ evident from reading the code itself. They are not a changelog of behavior, and must not restate anything a reader can infer directly from the code (a route's signature, a function's arguments, what a module exports). Most changes need no update: don't document a new route, metric, migration, or test that simply re-applies an existing pattern. Reach for an edit only when a change establishes, meaningfully alters, or removes such an architectural pattern, and prefer doing it when wrapping up rather than mid-task. If you do, update the file that owns it (repo-wide → this file or [STYLE_GUIDE.md](./STYLE_GUIDE.md); scoped to one package → that package's nested `AGENTS.md`; a workflow an agent follows → the relevant skill), and delete guidance a change makes obsolete in the same pass — stale guidance is worse than none. Reviewers check for drift as a backstop (see [.github/claude-review-prompt.md](.github/claude-review-prompt.md)).\n- Keep them **as concise as possible**: only the minimal directives an expert needs. No tutorials, no restating what the code already says.\n\n## Troubleshooting\n\n- **Integration tests run stale service code.** `@atproto/dev-env` loads services through their package exports from `dist`. After changing service code, run `pnpm run build` inside that service package before rerunning its integration tests.\n- **Stale codegen.** If the build fails due to a generated file in [packages/api](packages/api) being out of date, run `pnpm run codegen && pnpm run build` from that package, then re-run the build. This is only needed there because its `prebuild` step skips codegen as a performance optimization.\n- **Codegen ran but produced stale output.** Codegen relies on `pnpm build:tooling` to build the `@atproto/lex-cli` and `@atproto/lex-builder` packages first. If you see a codegen failure, run `pnpm build:tooling` from the root, then re-run codegen.\n- **End-to-end test fails with stale infra.** If docker containers persist across test runs, reset them with `cd packages/dev-infra && docker compose down --volumes`.\n- **Nothing else worked.** `make clean` wipes every installed dependency (`node_modules`), build artifact (`dist`, `*.tsbuildinfo`), and prebuild/codegen output across all packages; follow it with `pnpm install && pnpm run build` to restore a clean state.\n\n<!-- File: CLAUDE.md (Tokens: ~11 | Category: claude-rule) -->\n# CLAUDE.md\n\n@AGENTS.md\n\n<!-- File: .agents/skills/lex-client/SKILL.md (Tokens: ~3979 | Category: anthropic-skill) -->\n---\nname: lex-client\ndescription: >\n  Call AT Protocol XRPC services with the `Client` from `@atproto/lex`. Use\n  when making an authenticated or unauthenticated HTTP request to an XRPC\n  service, wiring one service to call another, logging in with OAuth or an app\n  password, reading or writing repository records, uploading or fetching blobs,\n  configuring headers, labelers, service proxying, retries, or request/response\n  validation, handling XRPC errors, or composing reusable client actions. Also\n  use when replacing `AtpAgent`, `XrpcClient`, or a hand-rolled `fetch` call to\n  an `/xrpc/` endpoint. For a full migration off the legacy client stack, use\n  the lexification-client skill.\ndisable-model-invocation: false\n---\n\n# high-level XRPC client\n\n`Client` wraps an authenticated (or anonymous) session with helpers for AT Proto\nrepo operations (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`applyWrites`, `uploadBlob`, `getBlob`) plus the low-level\n`call`/`xrpc`/`xrpcSafe` request methods. Reach for it whenever there is a\nsession to carry, or per-service config (headers, labelers, proxy target) worth\nencapsulating.\n\nThe standalone `xrpc()` / `xrpcSafe()` exports exist for one-off, session-less\ncalls. Their first argument is an `Agent | AgentOptions` — a URL string, `URL`,\n`AgentConfig` object, or bare `FetchHandler` — and they otherwise share the same\noptions, responses, and errors as the client methods.\n\n```ts\nimport { xrpc } from '@atproto/lex'\n\nawait xrpc(hostUrl, com.atproto.sync.requestCrawl, { body: { hostname } })\n```\n\n## Constructing\n\nThe constructor takes an `Agent` (anything with `fetchHandler`, optionally\n`did`) or `AgentOptions` (a URL string/`URL`, or an `AgentConfig`).\n\n```ts\nimport { Client } from '@atproto/lex'\n\n// Unauthenticated\nconst client = new Client('https://public.api.bsky.app')\n\n// OAuth — OAuthSession implements Agent\nimport { type OAuthSession } from '@atproto/oauth-client-node'\nconst session: OAuthSession = await oauthClient.restore(userDid)\nconst client = new Client(session)\n```\n\n### App password sessions (CLI / scripts / bots)\n\n`PasswordSession` also implements `Agent`, plus `AsyncDisposable` — prefer\n`await using` so the session is deleted server-side on scope exit.\n\n```ts\nimport {\n  LexAuthFactorError,\n  PasswordSession,\n} from '@atproto/lex-password-session'\n\n// Credentials as a single URL: https://<handle>:<app-password>@<pds-host>\nawait using session = await PasswordSession.login(\n  process.env.APP_PASSWORD_CREDENTIALS,\n)\nconst client = new Client(session)\n```\n\nLong-lived apps should persist session data via `onUpdated` and use\n`PasswordSession.resume(data, options)` on the next run rather than re-sending\ncredentials. Other statics: `createAccount(body, { service, ... })` and\n`delete(data)` (revoke stored session data without resuming it). `login` throws\n`LexAuthFactorError` when the account requires 2FA — catch it, prompt, and retry\nwith `authFactorToken`.\n\n### Server-side calls with a static credential\n\nNo session needed — put the credential in the constructor headers. Internal\nservices typically also relax response processing:\n\n```ts\nconst client = new Client(\n  {\n    service: config.serviceUrl,\n    headers: { authorization: `Bearer ${config.serviceApiKey}` },\n  },\n  // Trust internal services to send well-formed responses\n  { strictResponseProcessing: false, validateResponse: config.devMode },\n)\n```\n\nIf the credential or URL must be resolved per-request, supply a custom agent:\n\n```ts\nconst agent: Agent = {\n  get did() {\n    return undefined\n  },\n  async fetchHandler(path, init) {\n    const { serviceUrl, serviceApiKey } = await getConfig()\n    const headers = new Headers(init?.headers)\n    headers.set('authorization', `Bearer ${serviceApiKey}`)\n    return fetch(new URL(path, serviceUrl), { ...init, headers })\n  },\n}\n\nconst client = new Client(agent)\n```\n\n`AgentConfig` and `fetchHandler` are mutually exclusive — passing both throws a\n`TypeError`.\n\n### Service proxying, and one client for several services\n\n`service` and `labelers` set the `atproto-proxy` / `atproto-accept-labelers`\nheaders. Both are _defaults_ and are overridable per request, so a single\nAppView-configured client can still reach the user's PDS directly — pass `null`\nto suppress the header for that request.\n\n```ts\nconst client = new Client(session, {\n  service: 'did:web:api.bsky.app#bsky_appview',\n  labelers: ['did:plc:labeler1', 'did:plc:labeler2'],\n})\n\n// Proxied to the AppView (client default)\nawait client.call(app.bsky.feed.getTimeline)\n\n// Straight to the user's PDS\nawait client.xrpc(com.atproto.repo.getRecord, {\n  service: null,\n  labelers: null,\n  appLabelers: null,\n  params: { repo: client.assertDid, collection, rkey: 'self' },\n})\n```\n\nThe record helpers (`create`, `get`, `put`, `delete`, `list`, `listAll`,\n`*Record`, `applyWrites`, `uploadBlob`, `getBlob`) already default to\n`service: null` / `labelers: null`, since repo operations belong on the PDS. Set\nthem explicitly to proxy one.\n\n## Validation options\n\n```ts\nconst client = new Client(session, {\n  // Validate request bodies against the input schema before sending. Worth\n  // enabling in dev/test to catch schema mistakes at the source.\n  validateRequest: true, // default: false\n\n  // Validate response bodies against the output schema.\n  validateResponse: false, // default: true\n\n  // Strict Lex decoding. `false` accepts legacy blobs, datetimes without\n  // timezones, etc. — see the lex-schema skill.\n  strictResponseProcessing: false, // default: true\n})\n```\n\nAll three are overridable per-call on `call` / `xrpc` / `xrpcSafe`.\n\n## Authentication accessors\n\n```ts\nclient.did // DidString | undefined\nclient.assertAuthenticated() // throws if unauthenticated; narrows client.did\nclient.assertDid // DidString (throws if unauthenticated)\n```\n\n`assertAuthenticated()` is an assertion function, so TypeScript requires the\nreceiver to have an explicit type annotation. `const client = new Client(...)`\n(inferred) will fail with TS2775 — write `const client: Client = new Client(...)`\nor annotate the parameter.\n\n## Requests\n\n### `client.call()` — returns the body, throws on failure\n\n```ts\nimport { app } from './lexicons/index.js'\n\nconst profile = await client.call(app.bsky.actor.getProfile, {\n  actor: 'pfrazee.com',\n})\n\nawait client.call(app.bsky.feed.sendInteractions, { interactions })\n\nconst timeline = await client.call(\n  app.bsky.feed.getTimeline,\n  { limit: 50 },\n  { signal, maxRetries: 3, headers: { 'custom-header': 'value' } },\n)\n```\n\nThe input argument is positional and distinct from the options: for a query it\nis the `params`, for a procedure the body, for an action the input. Methods that\ntake neither can be called with the schema alone, but **the options must never\noccupy the input slot** — `client.call(schema, options)` sends `options` as\nquery parameters and fails validation. Use `client.call(schema, {}, options)`,\nor `client.xrpc(schema, options)`.\n\n### `client.xrpc()` — full response\n\n```ts\nconst response = await client.xrpc(app.bsky.feed.getTimeline, {\n  params: { limit: 50 },\n  signal,\n  headers: { 'custom-header': 'value' },\n})\nresponse.status\nresponse.headers.get('content-language')\nresponse.body\n```\n\nUnlike `call`, `xrpc` takes a single options object with `params` / `body` as\nnamed fields, so options-only calls are unambiguous.\n\n### `client.xrpcSafe()` — discriminated result\n\nSame request as `xrpc()`, but returns the failure instead of throwing:\n\n```ts\nconst result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {\n  params: { handle: 'alice.bsky.social' },\n})\nif (result.success) {\n  result.body\n} else if (result.matchesSchemaErrors()) {\n  result.error // narrowed to the errors declared by the schema\n} else {\n  throw result.reason\n}\n```\n\n### Errors\n\nAll failures extend `XrpcError` (`error`, `message`, `matchesSchemaErrors()`,\n`shouldRetry()`). The three concrete branches of `XrpcFailure`:\n\n| Class                      | Meaning                                                                 |\n| -------------------------- | ----------------------------------------------------------------------- |\n| `XrpcResponseError`        | Server returned a non-2xx status                                        |\n| `XrpcInvalidResponseError` | Response is non-compliant — unexpected status, wrong encoding, bad data |\n| `XrpcInternalError`        | Failed before a usable response existed (network, client)               |\n\nSubclasses worth narrowing on: `XrpcAuthenticationError` (401, exposes\n`wwwAuthenticate`), `XrpcResponseValidationError` (response failed schema\nvalidation), and `XrpcFetchError` (the underlying `fetch` threw — the retryable\ninternal error).\n\nOnly `XrpcResponseError` carries HTTP data. `status`, `headers`, and `body` do\n**not** exist on the `XrpcFailure` union, so narrow first:\n\n```ts\nif (!result.success && result instanceof XrpcResponseError) {\n  result.status\n  result.headers.get('retry-after')\n}\n```\n\n### Retries\n\n`call`, `xrpc`, `xrpcSafe`, and the standalone functions all retry internally.\nSet `maxRetries` (default `0`); tune with `minRetryTimeout` (500ms),\n`maxRetryTimeout` (30s), `retryTimeoutFactor` (2), `retryHeaders` (honour\n`Retry-After` / `RateLimit-Reset`, default `true`), or replace the predicate with\n`retry(failure, { counter })`. The defaults already cover the retryable status\ncodes (408, 425, 429, 5xx) and network errors, so do not wrap calls in a manual\nretry loop.\n\n## Records\n\n### `create` / `get` / `put` / `delete`\n\n```ts\nimport { currentDatetimeString } from '@atproto/lex'\n\nconst { uri, cid } = await client.create(app.bsky.feed.post, {\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\n\n// Literal-key records (profile is rkey 'self') need no rkey\nconst profile = await client.get(app.bsky.actor.profile)\nprofile.value.displayName\n\nconst post = await client.get(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n\nawait client.put(app.bsky.actor.profile, { displayName: 'New Name' })\nawait client.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' })\n```\n\n`rkey` is optional for `literal:` and auto-generated (`tid`) keys and required\notherwise; the type system enforces this, so a missing `rkey` shows up as a\ncompile error rather than a runtime one. Shared options: `validate` (ask the PDS\nto validate), `validateRequest` (validate locally before sending), `swapCommit`\n(optimistic concurrency on the commit CID), and for `put`/`delete` also\n`swapRecord` (on the record's own CID).\n\n### `list` / `listAll` — paginating a collection\n\nListed records are a discriminated union on `valid`: entries that fail schema\nvalidation still come back, with `value` widened to `LexMap`. Check `valid`\nbefore touching typed fields.\n\n```ts\nconst page = await client.list(app.bsky.feed.post, { limit: 50, reverse: true })\nfor (const record of page.records) {\n  if (record.valid) console.log(record.uri, record.value.text)\n}\nif (page.cursor) {\n  await client.list(app.bsky.feed.post, { cursor: page.cursor, limit: 50 })\n}\n```\n\n`listAll()` is the auto-paginating async generator — prefer it over hand-rolling\na cursor loop. It defaults to `maxRetries: 3` since a long walk is far more\nlikely to hit a transient failure:\n\n```ts\nfor await (const record of client.listAll(app.bsky.feed.post)) {\n  if (record.valid) console.log(record.value.text)\n}\n```\n\n### `applyWrites` — atomic batch writes\n\nAll ops succeed or fail together. Options: `repo` (defaults to `client.did`),\n`validate`, `swapCommit`.\n\n```ts\nconst response = await client.applyWrites((op) => [\n  op.create(app.bsky.feed.post, {\n    text: 'Hello!',\n    createdAt: currentDatetimeString(),\n  }),\n  op.update(app.bsky.actor.profile, { displayName: 'Alice' }),\n  op.delete(app.bsky.feed.post, { rkey: '3jxf7z2k3q2' }),\n])\n```\n\n`response.body.results` is **optional**, and is a union in which `deleteResult`\nhas no `uri` or `cid`. Narrow before reading them:\n\n```ts\nfor (const result of response.body.results ?? []) {\n  if (com.atproto.repo.applyWrites.createResult.$isTypeOf(result)) {\n    console.log(result.uri, result.cid)\n  }\n}\n```\n\nThe callback may also be a generator, which reads better for dynamic batches:\n\n```ts\nawait client.applyWrites(function* (op) {\n  for (const rkey of staleRkeys) yield op.delete(app.bsky.feed.post, { rkey })\n})\n```\n\n### Blobs\n\n```ts\nconst { body } = await client.uploadBlob(bytes, { encoding: 'image/png' })\nbody.blob // BlobRef — store this in a record\n\nconst image = await client.getBlob(did, cid)\n```\n\n## Labelers\n\n```ts\n// Process-global defaults, always sent with `;redact`\nClient.configure({ appLabelers: ['did:plc:labeler1'] })\n\n// Per-client, and mutable at runtime\nconst client = new Client(session, { labelers: ['did:plc:labeler3'] })\nclient.addLabelers(['did:plc:labeler4'])\nclient.setLabelers(['did:plc:labeler5'])\nclient.clearLabelers()\n```\n\nPass `appLabelers: null` on a request to drop the process-global ones. The usual\npost-login flow reads the user's preference:\n\n```ts\nconst { preferences } = await client.call(app.bsky.actor.getPreferences)\nconst pref = preferences.findLast(app.bsky.actor.defs.labelersPref.$isTypeOf)\nclient.setLabelers(pref?.labelers.map((l) => l.did) ?? [])\n```\n\n## Actions — composable client operations\n\nAn `Action<Input, Output>` is `(client, input, options) => Output | Promise<Output>`.\n`client.call()` accepts one anywhere a schema fits, so an action is\ninterchangeable with a lexicon method at the call site.\n\n```ts\nimport { type Action, Client, currentDatetimeString } from '@atproto/lex'\nimport { app } from './lexicons/index.js'\n\ntype LikeInput = { uri: AtUriString; cid: CidString }\n\nexport const likePost: Action<LikeInput, CreateOutput> = async (\n  client: Client, // explicit — required by assertAuthenticated() below\n  { uri, cid },\n  options,\n) => {\n  client.assertAuthenticated()\n  return client.create(\n    app.bsky.feed.like,\n    { subject: { uri, cid }, createdAt: currentDatetimeString() },\n    options,\n  )\n}\n\nawait client.call(likePost, { uri, cid })\n```\n\nActions compose — thread `options` through so cancellation propagates:\n\n```ts\nconst upsertPreference: Action<Preference, Preference[]> = async (\n  client,\n  pref,\n  options,\n) => {\n  const { preferences } = await client.call(\n    app.bsky.actor.getPreferences,\n    {},\n    options,\n  )\n  options?.signal?.throwIfAborted()\n  const updated = [...preferences.filter((p) => p.$type !== pref.$type), pref]\n  await client.call(\n    app.bsky.actor.putPreferences,\n    { preferences: updated },\n    options,\n  )\n  return updated\n}\n```\n\nExport actions individually rather than as one object, so consumers tree-shake\nwhat they don't call:\n\n```ts\n// actions.ts\nexport const post: Action</* ... */> = async (c, i, o) => {/* ... */}\nexport const follow: Action</* ... */> = async (c, i, o) => {/* ... */}\n\n// usage\nimport * as actions from './actions.js'\nawait client.call(actions.post, { text: 'Hello!' })\n```\n\nGuidelines: annotate as `Action<Input, Output>` (it contextually types the\nparameters); call `client.assertAuthenticated()` when auth is required;\n`options?.signal?.throwIfAborted()` between long steps; and handle swap-error\nretries here rather than pushing optimistic-concurrency handling onto callers.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  every client method, and `$isTypeOf` / `$build` / `$type`.\n- **[lex-data](../lex-data/SKILL.md)** — blobs, CIDs, branded strings, and\n  datetimes at the call boundary.\n- **[lexification-client](../lexification-client/SKILL.md)** — migrating from\n  `AtpAgent` / `XrpcClient`.\n- **[xrpc-server](../xrpc-server/SKILL.md)** — the other half, when the service\n  also _serves_ XRPC routes.\n- **[lex-setup](../lex-setup/SKILL.md)** — installing lexicons and configuring\n  the codegen that produces `./lexicons/index.js`.\n\n<!-- File: .agents/skills/lex-data/SKILL.md (Tokens: ~3926 | Category: anthropic-skill) -->\n---\nname: lex-data\ndescription: >\n  Represent, parse, serialize, hash, or convert AT Protocol data values with\n  `@atproto/lex`. Use whenever code touches CIDs, raw bytes, `$link` / `$bytes`\n  JSON, CBOR / DRISL encoding, blob references, or AT Protocol identifier and\n  datetime strings — DIDs, handles, AT URIs, NSIDs, TIDs, record keys,\n  `createdAt` / `indexedAt`. Applies when the ask sounds like \"serialize this\n  record\", \"hash these bytes\", \"why is this datetime rejected\", \"count the\n  characters in this field\", or when replacing `multiformats` CIDs,\n  `new Date().toISOString()`, or hand-rolled base64. Read it before typing a\n  DID, AT URI, or datetime as a bare `string`.\ndisable-model-invocation: false\n---\n\n# Data model: types, CIDs, JSON, CBOR, identifier strings, blobs\n\nThe AT Protocol data model is JSON plus two primitives — **CIDs**\n(content-addressed links) and **bytes** (raw binary) — encodable as JSON or as\nCBOR.\n\n## Which package exports what\n\n`@atproto/lex` re-exports `lex-data`, `lex-json`, `lex-schema` and\n`lex-client`, so a package that already depends on it needs no extra import\npath. `@atproto/lex-cbor` is **not** re-exported — depend on it explicitly.\n\n| Symbols                                                       | Defined in                                              | Reachable via `@atproto/lex` |\n| ------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------- |\n| `LexValue`, `Cid`, blob refs, base64/utf8 helpers, `LexError` | `@atproto/lex-data`                                     | yes                          |\n| `lexParse`, `lexStringify`, `jsonToLex`, `lexToJson`          | `@atproto/lex-json`                                     | yes                          |\n| Identifier & datetime string types + guards                   | `@atproto/syntax`, re-exported by `@atproto/lex-schema` | yes                          |\n| `encode`, `decode`, `decodeAll`, `cidForLex`                  | `@atproto/lex-cbor`                                     | **no**                       |\n\nLow-level packages (`repo`, `common`, `xrpc-server`) import `@atproto/lex-data`\ndirectly to avoid pulling in the client and schema layers.\n\n> [!NOTE]\n> This repo sets `verbatimModuleSyntax: true`, so type-only names need the\n> `type` modifier — `import { type DidString, isDidString } from '@atproto/lex'`.\n> All examples below follow that form.\n\n## Lex value types\n\n```ts\nimport {\n  type LexValue,\n  type TypedLexMap,\n  isLexValue,\n  isTypedLexMap,\n} from '@atproto/lex'\n\n// LexScalar:    number (integers only) | string | boolean | null | Cid | Uint8Array\n// LexValue:     LexScalar | LexValue[] | { [key: string]?: LexValue }\n// LexMap:       { [key: string]?: LexValue }\n// TypedLexMap:  LexMap & { $type: string }\n```\n\n`isLexValue`, `isLexMap`, `isLexArray` and `isLexScalar` take `unknown`;\n`isTypedLexMap` takes a `LexValue`, so narrow with `isLexValue` first when the\ninput is untyped. `isLexValue` walks the whole structure and rejects cycles,\nnon-integer numbers, and non-plain objects (`Date`, class instances,\nfunctions) — it is not a cheap check, so avoid it on hot paths where a schema\nalready validates the value.\n\n## CIDs\n\n`Cid` is an interface, not a class. `@atproto/lex-data` defines it rather than\nre-exporting `multiformats`' `CID`, because that class breaks under\n`node16` / `bundler` module resolution. Treat `CID` and `asMultiformatsCID` as\ndeprecated escape hatches.\n\n```ts\nimport {\n  type Cid,\n  asCid,\n  decodeCid,\n  ifCid,\n  isCid,\n  parseCid,\n  parseCidSafe,\n} from '@atproto/lex'\n\nparseCid(str) // Cid — throws on invalid\nparseCidSafe(str) // Cid | null\ndecodeCid(bytes) // Cid from binary form — throws on invalid\nisCid(value) // type guard\nifCid(value) // Cid | null\nasCid(value) // Cid — throws on invalid\n```\n\nEach accepts a `{ flavor }` option that additionally constrains the CID, which\nis how you enforce that a blob CID really is a blob CID rather than trusting\nthe caller:\n\n- `'raw'` — v1 + raw multicodec (blob content)\n- `'cbor'` — v1 + dag-cbor + sha256 (records, MST nodes)\n- `'dasl'` — either codec, v1 + sha256\n\nFor strings you already hold, `validateCidString(str, options)` returns a\nboolean and also checks the string is the canonical encoding;\n`ensureValidCidString` throws instead.\n\nCreating CIDs — hashing is async because it goes through WebCrypto:\n\n```ts\nimport {\n  cidForCbor,\n  cidForRawBytes,\n  cidForRawHash,\n  isCidForBytes,\n} from '@atproto/lex'\nimport { cidForLex } from '@atproto/lex-cbor'\n\nawait cidForLex(record) // CBOR-encode then hash — the usual entry point\nawait cidForCbor(cborBytes) // hash bytes that are already CBOR\nawait cidForRawBytes(blobBytes) // raw CID for blob content\ncidForRawHash(sha256Digest) // sync, when you already streamed the hash\nawait isCidForBytes(cid, bytes) // verify a CID matches its content\n```\n\nIn Lex JSON, CIDs are encoded as `{ \"$link\": \"bafyrei...\" }`.\n\n## Bytes\n\nBinary data is `Uint8Array`; JSON-encoded as `{ \"$bytes\": \"base64...\" }`.\n`@atproto/lex-data` ships the byte utilities so packages don't add their own\nbase64 dependency: `toBase64` / `fromBase64` (both take an optional\n`'base64' | 'base64url'` alphabet), `utf8ToBase64` / `utf8FromBase64`,\n`utf8FromBytes`, `ui8Concat`, `ui8Equals`, `asUint8Array`, `ifUint8Array`.\n\n## JSON ↔ Lex\n\n| Function       | Direction         | Input           | Output                                |\n| -------------- | ----------------- | --------------- | ------------------------------------- |\n| `lexParse`     | JSON string → Lex | `string`        | `LexValue` (with `Cid`, `Uint8Array`) |\n| `lexStringify` | Lex → JSON string | `LexValue`      | `string`                              |\n| `jsonToLex`    | parsed JSON → Lex | plain JS object | `LexValue`                            |\n| `lexToJson`    | Lex → plain JS    | `LexValue`      | plain JS object                       |\n\n`lexParse<T>()` takes a type parameter, so no `as` cast is needed:\n\n```ts\nimport { lexParse, lexStringify } from '@atproto/lex'\n\nconst sub =\n  lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(json)\nconst out = lexStringify({ ref: someCid, data: someBytes })\n```\n\n`lexParse` and `jsonToLex` accept `{ strict }`, defaulting to **`false`**:\nmalformed `$link` / `$bytes` objects and non-integer numbers pass through\nunchanged rather than throwing. Pass `{ strict: true }` when the input is\nuntrusted and a silently-wrong value would be worse than an exception.\n`lexParseJsonBytes(bytes, options)` is the same parse starting from UTF-8\nbytes.\n\nField-level encode/decode is rarely needed, but note the return contract —\nthese signal failure by returning `undefined`, they do not throw:\n\n```ts\nimport {\n  encodeLexBytes,\n  encodeLexLink,\n  parseLexBytes,\n  parseLexLink,\n} from '@atproto/lex'\n\nparseLexLink({ $link: 'bafy...' }) // Cid | undefined\nparseLexBytes({ $bytes: 'SGVsbG8=' }) // Uint8Array | undefined\nencodeLexLink(someCid) // { $link: '...' }\nencodeLexBytes(new Uint8Array([1, 2, 3])) // { $bytes: '...' }\n```\n\n## CBOR (DRISL)\n\nUse CBOR for repo storage, signed records, CAR files, and event frames —\nanywhere [DRISL](https://dasl.ing/drisl.html) deterministic encoding is\nrequired. Add `@atproto/lex-cbor` as a dependency; it is not reachable through\n`@atproto/lex`.\n\n```ts\nimport { type LexValue } from '@atproto/lex'\nimport { cidForLex, decode, decodeAll, encode } from '@atproto/lex-cbor'\n\nconst bytes: Uint8Array = encode(someLexValue)\nconst value = decode<{ foo: LexValue }>(bytes)\n\n// Concatenated values (CAR blocks, subscription frames)\nfor (const frame of decodeAll(buffer)) {\n  /* … */\n}\n```\n\n`encode` throws on anything the AT data model forbids: non-string map keys,\n`undefined`, and non-integer numbers. That strictness is the point — it is what\nmakes the resulting CID stable.\n\n## Datetime strings\n\n`DatetimeString` is the branded type for AT Protocol datetimes.\n`Date.prototype.toISOString()` is not guaranteed to conform (years outside\n0–9999 serialize with a `±YYYYYY` prefix), so route `Date` → string through\nthe helpers rather than calling `toISOString()` directly:\n\n```ts\nimport {\n  type DatetimeString,\n  asDatetimeString,\n  currentDatetimeString,\n  ifDatetimeString,\n  isDatetimeString,\n  toDatetimeString,\n} from '@atproto/lex'\n\ncurrentDatetimeString() // now\ntoDatetimeString(date) // throws InvalidDatetimeError on an out-of-range date\nasDatetimeString(str) // validates + brands a string, throws on invalid\nisDatetimeString(value) // type guard\nifDatetimeString(value) // DatetimeString | undefined\n```\n\n`isDatetimeStringLenient` accepts ISO-ish strings that the spec rejects (e.g.\nmissing a timezone) — use it when reading legacy records, not when writing.\nFor repairing bad historical `createdAt` values, `normalizeDatetime` /\n`normalizeDatetimeAlways` live in `@atproto/syntax` only; they are not\nre-exported from `@atproto/lex`.\n\nUse `DatetimeString` on record and DB row types so a raw `string` can't drift\nin:\n\n```ts\nimport type { DatetimeString, DidString } from '@atproto/lex'\n\ninterface Row {\n  did: DidString\n  indexedAt: DatetimeString\n}\n```\n\n## Identifier strings\n\nNominal string types keep unvalidated `string`s from being passed where a\nvalidated identifier is expected.\n\n| Type                 | Format                     | Guard                  |\n| -------------------- | -------------------------- | ---------------------- |\n| `DidString`          | `did:method:specific-id`   | `isDidString`          |\n| `HandleString`       | DNS-style handle           | `isHandleString`       |\n| `AtIdentifierString` | DID **or** handle          | `isAtIdentifierString` |\n| `AtUriString`        | `at://…`                   | `isAtUriString`        |\n| `UriString`          | any `scheme:…` URI         | `isUriString`          |\n| `NsidString`         | `app.bsky.feed.post`       | `isNsidString`         |\n| `TidString`          | timestamp identifier       | `isTidString`          |\n| `RecordKeyString`    | rkey (TID or literal)      | `isRecordKeyString`    |\n| `CidString`          | CID in string form         | `isCidString`          |\n| `LanguageString`     | BCP-47 tag (`en`, `pt-BR`) | `isLanguageString`     |\n| `DatetimeString`     | AT Proto datetime          | `isDatetimeString`     |\n\nOnly `AtUriString`, `AtIdentifierString` and `DatetimeString` also ship\n`as…` / `assert…` / `if…` variants. For the rest, the generic format helpers\ncover the same ground:\n\n```ts\nimport { asStringFormat, ifStringFormat, isStringFormat } from '@atproto/lex'\n\nisStringFormat(value, 'did') // narrows to DidString\nasStringFormat(value, 'nsid') // throws TypeError on invalid\nifStringFormat(value, 'handle') // HandleString | undefined\n```\n\n`isAtUriString`, `isLanguageString` and `isStringFormat` accept\n`{ strict: false }` for a lenient pass that tolerates non-conforming\nreal-world data.\n\n> [!NOTE]\n> These types are defined in `@atproto/syntax` and re-exported through\n> `@atproto/lex`. Import from `@atproto/lex` where the package already depends\n> on it — that keeps identifier types and generated schemas coming from one\n> place and avoids adding a `@atproto/syntax` dependency.\n\n### Boundaries\n\nProtobuf messages, data-plane responses and Kysely rows all arrive as bare\n`string`. Brand them once at the boundary so downstream code is typed, instead\nof asserting at each use:\n\n```ts\ndids: dids as DidString[],\npost: { uri: item.uri as AtUriString, cid: item.cid || undefined },\n```\n\nAn `as` cast is appropriate where the value provably came from a validated\nsource (your own DB, a schema-validated response). Where it did not — env\nvars, CLI args, user input — run the guard: `assert(isDidString(serverDid))`.\n\n## Blob references\n\nTwo shapes coexist on the network:\n\n```ts\ntype TypedBlobRef = { $type: 'blob'; ref: Cid; mimeType: string; size: number }\ntype LegacyBlobRef = { cid: string; mimeType: string } // no $type, no size\ntype BlobRef = TypedBlobRef | LegacyBlobRef\n```\n\nNew uploads are always `TypedBlobRef` — the PDS `uploadBlob` endpoint returns\none. Reading code has to tolerate both, which is what the format-agnostic\naccessors are for:\n\n```ts\nimport {\n  type BlobRef,\n  enumBlobRefs,\n  getBlobCid,\n  getBlobCidString,\n  getBlobMime,\n  getBlobSize,\n  isLegacyBlobRef,\n  isTypedBlobRef,\n} from '@atproto/lex'\n\ngetBlobCid(blob) // Cid — parses the string for legacy refs, so it can throw\ngetBlobCidString(blob) // string — skips the parse, prefer it when you only need the string\ngetBlobMime(blob) // string\ngetBlobSize(blob) // number | undefined — legacy refs carry no size\n\n// Deep-walk a record for its blobs (what the PDS does on record write/import)\nfor (const ref of enumBlobRefs(record, { allowLegacy: true, strict: false })) {\n  /* … */\n}\n```\n\n`enumBlobRefs` skips legacy refs unless `allowLegacy: true`.\n\n`BlobRef` is an interface, so `instanceof` does not apply — use `isBlobRef` /\n`isTypedBlobRef` / `isLegacyBlobRef`.\n\n### What `strict` means for blobs\n\nThe word means different things at two layers, and conflating them causes\nconfusing validation failures:\n\n- On the **guards** (`isBlobRef`, `isTypedBlobRef`, `isLegacyBlobRef`,\n  `enumBlobRefs`), `strict` constrains the _CID flavor_. Default `true`\n  requires a raw v1 sha256 CID; `strict: false` accepts any CID (and tolerates\n  `size: -1`). It does not reject the legacy shape — `isBlobRef` accepts legacy\n  refs either way.\n- On **schema validation** (`l.blob()`), `strict: true` (the default) rejects\n  legacy refs outright and enforces `accept` / `maxSize`; `strict: false`\n  admits them and skips those checks.\n\nA `Client` constructed with `strictResponseProcessing: false` threads\n`strict: false` through both response parsing and schema validation, which is\nhow you read a server that still emits legacy refs.\n\n## String lengths\n\nLexicon string constraints are measured in graphemes (user-perceived\ncharacters) or UTF-8 bytes, never in JS string length:\n\n```ts\nimport { graphemeLen, utf8Len } from '@atproto/lex'\n\ngraphemeLen('👨‍👩‍👧‍👦') // 1\nutf8Len('👨‍👩‍👧‍👦') // 25\n```\n\n## Comparison\n\n`lexEquals(a, b)` deep-compares two `LexValue`s, handling `Cid` and\n`Uint8Array` where `===` and `JSON.stringify` would not.\n\n## Related skills\n\n- [lex-schema](../lex-schema/SKILL.md) — validating these values against\n  Lexicon schemas.\n- [lex-setup](../lex-setup/SKILL.md) — adding `@atproto/lex-cbor` and the rest\n  of the package family to a package.\n- [lexification-client](../lexification-client/SKILL.md) — migrating off\n  `multiformats` `CID`, the `BlobRef` class from `@atproto/lexicon`, and\n  `jsonStringToLex` / `stringifyLex`.\n\n<!-- File: .agents/skills/lex-schema/SKILL.md (Tokens: ~3213 | Category: anthropic-skill) -->\n---\nname: lex-schema\ndescription: >\n  Validate, parse, construct, and type AT Protocol data against Lexicon schemas\n  with `@atproto/lex` / `@atproto/lex-schema`. Use whenever code reaches into a\n  generated `./lexicons/` tree — `$parse`, `$safeParse`, `$validate`,\n  `$matches`, `$assert`, `$build`, `$isTypeOf`, `$type`, `$nsid`, `$lxm`,\n  `$Params`, `$Input`, `$Output`, `$Message`, `.Main` — when checking whether an\n  unknown record or XRPC payload conforms, when loosening checks with\n  `strict: false`, when declaring a schema by hand with the `l` builder, when\n  handing a Lexicon schema to a Standard Schema consumer, or when replacing\n  Zod/Ajv-style validation with Lexicon validation. Not for codegen setup (see\n  lex-setup) nor for the value types themselves — CIDs, bytes, blobs, JSON/CBOR\n  encoding, branded strings (see lex-data).\ndisable-model-invocation: false\n---\n\n# Generated schemas and the `l` builder\n\n`lex build` compiles each Lexicon document into a TypeScript module carrying\nboth a runtime schema and its types, addressed by NSID dot-path through the\nnamespace tree:\n\n```ts\nimport { app, com } from './lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def schema\ncom.atproto.repo.createRecord // query/procedure schema\n```\n\nThe tree lives in `./src/lexicons/` (plural), is gitignored, and is regenerated\nby `lex build` — never edit it. See [lex-setup](../lex-setup/SKILL.md).\n\n## Accessors on a generated namespace\n\n| Accessor                | Schema kind             | What it is                                          |\n| ----------------------- | ----------------------- | --------------------------------------------------- |\n| `$nsid`                 | all                     | NSID of the document (`'app.bsky.feed.post'`)       |\n| `$type`                 | record / typed object   | `$type` string; equals `$nsid` for `main`           |\n| `$lxm`                  | query / procedure / sub | XRPC method ID — auth checks, proxy routing         |\n| `$params` / `$Params`   | query / procedure / sub | Params schema / its parsed type                     |\n| `$input` / `$Input`     | procedure               | Request payload schema / `{ encoding, body }` type  |\n| `$InputBody`            | procedure               | Request body type alone                             |\n| `$output` / `$Output`   | query / procedure       | Response payload schema / `{ encoding, body }` type |\n| `$OutputBody`           | query / procedure       | Response body type alone                            |\n| `$message` / `$Message` | subscription            | Message schema / message type                       |\n| `.Main`                 | record / object         | The main definition's TS type                       |\n\n`$Input` / `$Output` / `$InputBody` / `$OutputBody` take an optional binary-body\ntype parameter (`$Output<Buffer>`); the default is the opaque `l.BinaryData`\nplaceholder.\n\nSub-definitions sit alongside `.Main` — type in PascalCase, schema in camelCase:\n\n```ts\ntype ReplyRef = app.bsky.feed.post.ReplyRef\napp.bsky.feed.defs.postView // schema\ntype PostView = app.bsky.feed.defs.PostView // type\n```\n\n**Never write NSID string literals.** Use `$type` / `$lxm` / `$token`: the\nschema stays the single source of truth and each constant is one shared string\ninstance (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md#lexicons)). Tokens are\nalso available as PascalCase export (e.g., `app.bsky.feed.defs.RequestLess`),\nprefer this over the `$token` notation.\n\n## Validation methods\n\nEvery schema exposes these as plain methods; generated `main` definitions also\nre-export them as pre-bound `$`-prefixed consts, so\n`const { $parse } = app.bsky.feed.post` and\n`export const validateStrongRef = com.atproto.repo.strongRef.$safeValidate`\nboth work.\n\n| Method                       | On invalid  | Transforms input?      | Returns                        |\n| ---------------------------- | ----------- | ---------------------- | ------------------------------ |\n| `$parse(data, opts?)`        | throws      | yes                    | parsed value (output type)     |\n| `$safeParse(data, opts?)`    | result      | yes                    | `{ success, value }` \\| error  |\n| `$validate(data, opts?)`     | throws      | no                     | the _same_ value, narrowed     |\n| `$safeValidate(data, opts?)` | result      | no                     | `{ success, value }` \\| error  |\n| `$assert(data, opts?)`       | throws      | no                     | `void`, narrows via `asserts`  |\n| `$check(data, opts?)`        | throws      | no                     | `void`, no narrowing           |\n| `$cast(data, opts?)`         | throws      | no                     | same as `$validate`            |\n| `$matches(data, opts?)`      | `false`     | no                     | type-guard boolean             |\n| `$ifMatches(data, opts?)`    | `undefined` | no                     | the value, or `undefined`      |\n| `$isTypeOf(value)`           | `false`     | no — **no validation** | type-guard on `$type` only     |\n| `$build(data)`               | n/a         | adds `$type` only      | typed literal, **unvalidated** |\n\n### Parse vs. validate\n\nThe distinction is whether the schema may hand back a _different_ value.\n`$parse` runs in parse mode: `withDefault` defaults are filled in, `l.bytes()`\nnormalizes any typed-array view to `Uint8Array`, a `TokenSchema` instance is\naccepted in place of its string, and objects/arrays/params are copied on write\nwhen a child transforms. `$validate` runs the same checks but treats any\ntransformation as a failure, so a success guarantees the input already\nconformed as-is.\n\nThat guarantee is why validate mode is the right choice on the read path: the\nAppView hydrates stored records with `$matches` / `$safeValidate` rather than\n`$parse`, because a value with defaults applied would no longer hash to the CID\nit was stored under.\n\nParse mode does **not** decode the Lex JSON encoding — `{ \"$link\": … }` stays a\nplain object and fails CID validation. Convert first with `jsonToLex` /\n`lexParse` from `@atproto/lex-json` (see [lex-data](../lex-data/SKILL.md)).\n\n### Result shape\n\nOn failure the result _is_ a `LexValidationError` (the class implements the\nfailure shape), so `result.reason` is the error itself and `result.issues` is\nthe issue list. There is no `.error` payload object — `error` is the XRPC error\ncode string (`'InvalidRequest'`).\n\n```ts\nconst result = app.bsky.feed.post.$safeParse(unknownData)\nif (result.success) {\n  result.value // app.bsky.feed.post.Main\n} else {\n  result.reason // LexValidationError (=== result)\n  result.issues // readonly Issue[], each carrying a path\n}\n```\n\n### `$assert` vs `$check`\n\n`$assert` narrows via an `asserts` signature, which TypeScript only permits when\nthe call target has an explicit type annotation. In generic code that yields:\n\n> Assertions require every name in the call target to be declared with an\n> explicit type annotation. `ts(2775)`\n\n`$check` is the same runtime check without the narrowing — reach for it only to\nresolve that error.\n\n```ts\ndeclare const schema: RecordSchema | ObjectSchema\nschema.$check(data) // void, no ts(2775)\n```\n\n### `$isTypeOf` — discriminate, don't validate\n\nOnly compares `$type`, so it is cheap enough for hot `.find` / `.filter` loops\nover already-validated unions:\n\n```ts\nconst pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nMind the asymmetry: on a **record** schema it demands an exact `$type` match,\nbut on a **typed object** (def) schema a _missing_ `$type` also passes, since\n`$type` is optional on nested defs. On unvalidated data it proves nothing about\nthe rest of the shape.\n\n### `$build` — construct with `$type`, no validation\n\nRecords require `$type`, so hand-building one means repeating the NSID.\n`$build` sets it and types the result; it applies no defaults and runs no\nchecks, so follow with `$parse` when the input is untrusted.\n\n```ts\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello, world!',\n  createdAt: l.currentDatetimeString(),\n})\n```\n\nDefs whose `$type` is optional need no runtime call — annotate instead. Use\n`Un$Typed<T>` when a helper returns a def's fields and the caller adds `$type`:\n\n```ts\nconst view: app.bsky.feed.defs.PostView = {/* ... */}\nfunction toPrefs(): Un$Typed<app.bsky.notification.defs.Preferences> {\n  /* ... */\n}\n```\n\n### Record keys\n\nA record schema carries its key schema, for validating an rkey independently of\nthe record body:\n\n```ts\nconst result = app.bsky.feed.post.keySchema.safeValidate(rkey)\n```\n\n## Strict mode\n\nEvery validating method takes `{ strict?: boolean, path?: PropertyKey[] }`,\nstrict defaulting to `true`. Strict means \"conforms to the AT Protocol spec\";\nlenient exists because data already on the network predates parts of it.\n\n| Concern         | `strict: true` (default)                  | `strict: false`                             |\n| --------------- | ----------------------------------------- | ------------------------------------------- |\n| `datetime`      | timezone required                         | any ISO-ish datetime                        |\n| `at-uri`        | record-key component validated            | record key not validated                    |\n| `language`      | RFC 5646 §4.1 semantics enforced          | well-formed BCP 47 syntax only              |\n| blob constraint | `accept` MIME list and `maxSize` enforced | not enforced                                |\n| blob ref CID    | strict CID check                          | any valid CID                               |\n| legacy blob ref | `{ cid, mimeType }` rejected              | accepted, including the sentinel `size: -1` |\n\n```ts\nschema.$safeParse(data, { strict: false })\n```\n\n`Client`'s `strictResponseProcessing: false` threads this through every\nresponse — prefer it over sprinkling per-call options. See\n[lex-client](../lex-client/SKILL.md).\n\n## Tokens\n\nA Lexicon `token` def exports its schema in camelCase and its string value in\nPascalCase. The PascalCase export shares its name with the string literal type:\n\n```ts\napp.bsky.graph.defs.curatelist // TokenSchema\napp.bsky.graph.defs.Curatelist // 'app.bsky.graph.defs#curatelist'\ntype Curatelist = app.bsky.graph.defs.Curatelist\n```\n\n## Declaring schemas with `l`\n\nGenerated schemas cover anything backed by a Lexicon document. Use the `l`\nbuilder for one-off internal shapes, or when authoring a schema before its\nLexicon JSON exists — otherwise prefer `./lexicons/`, which stays in sync with\nthe contract.\n\n```ts\nimport { l } from '@atproto/lex'\n\nconst thing = l.typedObject(\n  'com.example.thing', // NSID\n  'main', // definition name\n  l.object({\n    name: l.string({ maxGraphemes: 64 }),\n    count: l.optional(l.withDefault(l.integer({ minimum: 0 }), 0)),\n  }),\n)\n\ntype Thing = l.Infer<typeof thing> // input type; l.InferOutput for post-parse\n```\n\nNote the shape: document builders take `(nsid, name, validator)` — a validator,\nnot a bare property map. `l.object()` is what turns a property map into one.\n\n- Primitives — `l.string()`, `l.integer()`, `l.boolean()`, `l.bytes()`,\n  `l.cid()`, `l.blob()`, `l.null()`, `l.never()`, `l.unknown()`, `l.lexMap()`,\n  `l.lexValue()`\n- Composites — `l.object()`, `l.array()`, `l.dict()`, `l.union()`,\n  `l.discriminatedUnion()`, `l.intersection()`, `l.ref()`, `l.literal()`,\n  `l.enum()`, `l.regexp()`, `l.refine()`, `l.custom()`\n- Modifiers — `l.optional()`, `l.nullable()`, `l.withDefault()`\n- Lexicon documents — `l.record()`, `l.typedObject()`, `l.typedRef()`,\n  `l.typedUnion()`, `l.token()`, `l.query()`, `l.procedure()`,\n  `l.subscription()`, `l.params()`, `l.payload()`, `l.jsonPayload()`\n\n`l` also carries the schema classes (`RecordSchema`, `ObjectSchema`, …), the\ninference helpers (`l.Infer`, `l.InferInput`, `l.InferOutput`), the branded\nstring types and format guards, and the datetime helpers\n(`l.currentDatetimeString`, `l.toDatetimeString`, `l.asDatetimeString`,\n`l.isDatetimeString`, `l.ifDatetimeString`).\n\n`@atproto/lex` re-exports all of it flat as well, but importing `l` avoids\ncolliding with globals and generic names (`string`, `object`, `record`).\n\n`ParamsSchema` additionally exposes `fromURLSearchParams()` (coercing each\nstring to the declared scalar type) and `toURLSearchParams()` — use these\ninstead of hand-rolled query-string handling.\n\n## Standard Schema\n\nEvery schema implements [Standard Schema](https://standardschema.dev/) v1 via\n`~standard`, so form libraries and validators speaking the spec accept a\nLexicon schema directly:\n\n```ts\nconst result = app.bsky.feed.post['~standard'].validate(data)\nif (!result.issues) result.value\n```\n\nThe adapter always runs in **parse mode**, so defaults and coercion apply even\nwhere a caller might expect a pure check.\n\n## Related skills\n\n[lex-setup](../lex-setup/SKILL.md) generates this tree;\n[lex-data](../lex-data/SKILL.md) covers the values these schemas validate\n(CIDs, bytes, blobs, JSON/CBOR, branded strings);\n[lex-client](../lex-client/SKILL.md) and\n[xrpc-server](../xrpc-server/SKILL.md) cover passing schemas to calls and\nroutes.\n\n<!-- File: .agents/skills/lex-setup/SKILL.md (Tokens: ~4561 | Category: anthropic-skill) -->\n---\nname: lex-setup\ndescription: >\n  Wire up, maintain, or troubleshoot `@atproto/lex` code generation and the\n  per-package `lexicons.json` manifest. Use when a package needs generated\n  Lexicon schemas; when adding or editing a `lexicons.json`, its `resolvers`\n  (`directory` / `http` / `repo`) or a `lexicons-overrides` directory; when\n  migrating a package off compiling the repo-root `lexicons` directory; when\n  adding a `codegen:lex` / `lex:install` / `prebuild` script; when `./src/lexicons/`\n  or `./lexicons/` is missing, stale, or gitignored wrong; after editing anything\n  under `lexicons/`; when adding an NSID; when `pnpm codegen`, `lex build`, or\n  `lex install` fails; when `lex` resolves to the wrong binary; when a\n  post-lexicon-edit build reports \"type X is not assignable to Y\"; or when ripping\n  out legacy `@atproto/lex-cli` codegen. Reach for it before hand-editing anything\n  under a `lexicons/`, `lexicons-overrides/`, or `src/lexicons/` directory, or any\n  `lexicons.json`.\ndisable-model-invocation: false\n---\n\n# `@atproto/lex` codegen setup\n\nTwo commands do everything:\n\n- **`lex build`** — compiles Lexicon JSON into a TypeScript schema tree. Every\n  package that uses `@atproto/lex` runs this.\n- **`lex install`** — resolves the Lexicons a package's `lexicons.json` lists\n  into `./lexicons/`, recording each one's source + CID, from the manifest's\n  `resolvers` and the network.\n\nBoth ship in the `lex` bin of `@atproto/lex`. Add `@atproto/lex` as a\ndependency — it re-exports `@atproto/lex-client`, `-schema`, `-data`, and\n`-json`, so one dependency covers the common case. `@atproto/lex-cbor` is\n_not_ re-exported; depend on it directly if the package encodes/decodes CBOR.\n\n## Wiring a package in this monorepo\n\n[lexicons/](../../../lexicons/) at the repo root is the canonical source for the\n`com.atproto` and `tools.ozone` namespaces **only**. Every package manages its\nLexicons through its own committed `lexicons.json` manifest and resolves them\nwith `lex install` — no package compiles the root directory directly. The\nmanifest's `resolvers` decide where each NSID comes from; see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson).\n\nCopy the shape consuming packages use\n([packages/pds](../../../packages/pds/package.json),\n[packages/sync](../../../packages/sync/package.json),\n[packages/bsky](../../../packages/bsky/package.json)):\n\n```json\n{\n  \"scripts\": {\n    // Generate TypeScript schema from Lexicon JSON\n    \"lex:build\": \"lex build --clear --indexFile\",\n    // Install Lexicon JSON into ./lexicons/ and update the manifest. Use `pnpm lex:install foo.bar.baz` to install new lexicons\n    \"lex:install\": \"lex install\",\n    // Keep the TypeScript schema up to date after installing new lexicons\n    \"postlex:install\": \"pnpm run lex:build\",\n    // Allows to keep the manifest.json up-to-date when a canonical lexicon is updated. Also allows to fetch new lexicons from the network.\n    \"lex:update\": \"lex update\",\n    // Run the build step after updating the lexicons\n    \"postlex:update\": \"pnpm run lex:build\",\n    // When performing a regular build (eg. from CI), ensure that the lexicons are installed and match the manifest.\n    \"codegen:lex\": \"pnpm run lex:install --ci\",\n    \"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n    \"build\": \"tsc --build tsconfig.build.json\"\n  }\n}\n```\n\nThen gitignore the build artifacts. The generated TypeScript (`src/lexicons`) is\nnever committed, and the canonical schemas symlinked into `./lexicons` from the\nrepo root (`com.atproto` / `tools.ozone`) are gitignored too — they can't drift\nand are recreated on every install. Everything else resolved into `./lexicons` is\n**committed**: the `at://` network copies and the `app.bsky.*` / `chat.bsky.*`\ndocuments fetched from the bsky GitHub repo, so builds and CI work offline (the\nnetwork can't serve old record versions). The shared repo-root\n[lexicons-overrides/](../../../lexicons-overrides/) is committed too. See\n[packages/bsky/.gitignore](../../../packages/bsky/.gitignore) for the shape:\n\n```\n# @atproto/lex\n/src/lexicons\n# Canonical schemas are symlinked from the repo-wide source; everything else\n# under ./lexicons is a committed copy kept for offline builds and CI.\n/lexicons/com/atproto\n/lexicons/tools/ozone\n```\n\nA package whose Lexicons are _all_ symlinked canonical (e.g.\n[packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json))\nhas nothing to commit under `./lexicons` and can gitignore the whole `/lexicons`\ntree instead.\n\nThe indirection through `codegen:lex` is deliberate:\n\n- `prebuild` runs every `codegen:*` script the package declares, so a package\n  that also generates protobuf or templates needs no change to this line.\n  `enable-pre-post-scripts = true` in `.npmrc` is what makes a build fire\n  `prebuild` at all.\n- Root `pnpm codegen` runs `build:tooling` and then every `codegen:*` script\n  across the workspace in parallel. A generator hidden inside `prebuild` would\n  be skipped by it.\n\n### Which `lex` binary you get\n\n`@atproto/lex-cli` (legacy) and `@atproto/lex` both claim the `lex` bin name,\nand resolution is per-package. In `packages/api` — which still depends on\n`lex-cli` for `gen-api` — `pnpm exec lex` gives you the **legacy** CLI, not\n`lex build`. `api` therefore installs its manifest with the `lex-installer`\nbin (`@atproto/lex-installer`) and feeds the installed `./lexicons` to\n`gen-api`. Everywhere else `lex` is `@atproto/lex`.\n\n`gen-api` is slow, so `api`'s `lex:gen` is mtime-guarded: it only re-runs when\n`./src/client/index.ts` is missing or some lexicon content is newer than it.\nUse `find -L ./lexicons -type f` (not the symlinks themselves) — `lex install`\nre-creates the canonical symlinks and rewrites `lexicons.json` on every run, so\ntheir mtimes are meaningless; `-L` follows each symlink to its (stable) target,\nand the regular files are the committed/fetched copies.\n\nIf a `lex build` invocation errors about an unknown command, that collision is\nwhy. `@atproto/lex` also registers `ts-lex`, which is unambiguous. Outside a\nworkspace, `npx @atproto/lex` avoids pulling the unrelated unscoped `lex`\npackage that bare `npx lex` would fetch.\n\n### Manifest resolvers (`lexicons.json`)\n\nThe manifest's `lexicons` array lists the NSID **entry points** the package\nneeds; `lex install` pulls in schemas they reference transitively, so you only\nlist the roots. `resolvers` is an **ordered** list deciding where each NSID is\nfetched from — earlier entries win, with the AT Protocol network as the implicit\nlast fallback. The kinds:\n\n- `{ \"type\": \"directory\", \"path\": \"../../lexicons\", \"include\": [\"com.atproto.*\", \"tools.ozone.*\"] }`\n  — resolve from a local directory. The canonical set lives at the repo root;\n  matches are **symlinked** into `./lexicons` and locked with a `file://` URI, so\n  they can't drift. Adjust `path` for the package's depth.\n- `{ \"type\": \"http\", \"base\": \"https://raw.githubusercontent.com/bluesky-social/bsky/refs/heads/main/lexicons/\", \"include\": [\"app.bsky.*\", \"chat.bsky.*\"] }`\n  — fetch `<base>/<…nsid.segments>.json` (e.g. a raw Git host). `http` falls\n  through on 404 but throws on other errors.\n- `{ \"type\": \"repo\", \"repo\": \"<handle|did>\" }` — resolve each NSID from that\n  repo's `com.atproto.lexicon.schema` records; never falls through.\n- A `directory` resolver pointing at the shared, committed repo-root\n  **`../../lexicons-overrides`** directory, listed **after** the canonical\n  resolver but **before** the `http`/network ones, so it overrides the lexicons\n  they would otherwise return (it cannot override canonical, which resolves\n  first). Put any document that must differ here: unpublished NSIDs (`internal.*`,\n  `place.stream.badge.defs`, a not-yet-published `app.bsky.unspecced.*`),\n  documents whose local version is ahead of the published one, and minimal\n  compatibility relaxations of published third-party lexicons that network\n  records violate (see\n  [Compatibility overrides](../../../lexicons-overrides/README.md#compatibility-overrides);\n  the PDS must `exclude` them so writes keep validating against the published\n  schema). All packages share\n  the one [lexicons-overrides/](../../../lexicons-overrides/) at the repo root.\n\n[packages/bsky/lexicons.json](../../../packages/bsky/lexicons.json) is the full\nreference (overrides + canonical directory + GitHub `http`).\n\n`include`/`exclude` globs accept `*` as a wildcard; `exclude` is applied after\n`include`.\n\n### Packages under `packages/lex/`\n\n`@atproto/lex` depends on the SDK sub-packages, so they cannot depend back on it\n(it would be circular). They generate with `@atproto/lex-builder`'s `lex-builder\nbuild --lib @atproto/lex-schema` (that is where the generated files import `l`\nfrom) instead of `lex build`, and install their manifest with `@atproto/lex-installer`'s\n`lex-installer install` instead of `lex install`:\n\n```jsonc\n\"lex:build\": \"lex-builder build --out ./src/lexicons --clear --lib @atproto/lex-schema --index-file\",\n\"lex:install\": \"lex-installer install\",\n\"postlex:install\": \"pnpm run lex:build\",\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n```\n\nSee [packages/lex/lex-password-session](../../../packages/lex/lex-password-session/package.json).\nAdd `--default-export` to `lex:build` for a browser-facing client\n([packages/lex/lex-client](../../../packages/lex/lex-client/package.json)).\n\n## `lex build` flags\n\nDefaults: `--lexicons ./lexicons`, `--out ./src/lexicons`, `--no-pretty`,\n`--default-export`. yargs accepts either kebab-case or camelCase\n(`--index-file` == `--indexFile`).\n\n- `--clear` — wipe the output dir first, so deleted lexicons don't linger\n- `--override` — overwrite existing files (no-op alongside `--clear`)\n- `--indexFile` — emit `index.ts` re-exporting root namespaces (`app`, `com`, …)\n- `--include` / `--exclude <patterns…>` — filter by NSID\n- `--lib <package>` — where generated files import `l` from\n- `--importExt <ext>` — import specifier extension (`.js`; `\"\"` for extensionless)\n- `--fileExt <ext>` — emitted file extension (`.ts`)\n- `--no-pretty` — skip prettier\n- `--no-defaultExport` — drop `default` re-exports (see [Tree-shaking](#tree-shaking))\n- `--defsExport` — also expose defs under `$defs`, for when a child namespace\n  shadows a sibling definition (`com.example.foo` vs `com.example.foo.bar`)\n- `--ignore-errors` / `--ignore-invalid-lexicons` — skip bad inputs instead of failing\n\n## Using the generated tree\n\n`--indexFile` gives one entry point for all root namespaces; schemas are\naddressed by NSID dot-path:\n\n```ts\nimport { app, com, chat } from '../lexicons/index.js'\n\napp.bsky.feed.post // record schema\napp.bsky.feed.defs.postView // object def\ncom.atproto.repo.getRecord // query/procedure schema\n```\n\nEach NSID compiles to a `<name>.defs.ts` holding the schema plus a thin\n`<name>.ts` re-exporting it (and `main` as `default`). Nothing under\n`src/lexicons/` is hand-editable — it is deleted and rewritten on every\n`--clear` build.\n\n### Tree-shaking\n\nThe generated tree is tree-shakeable, but the reference style decides how much\nsurvives. A bundler can't tell that `client.call()` only consumes `.main`, so\nnaming the namespace retains every sibling def inside it:\n\n```ts\n// Smallest — default import; preferred for browser bundles\nimport getRecord from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(getRecord, {})\n\n// Same size, but leaks the `main` identifier into your source\nimport { main as getRecord } from '../lexicons/com/atproto/repo/getRecord.js'\nawait client.call(com.atproto.repo.getRecord.main, {})\n\n// Largest — namespace notation drags in sibling defs\nawait client.call(com.atproto.repo.getRecord, {})\n```\n\nServices, scripts, and tests here use namespace notation, which reads closest\nto the NSID and costs nothing server-side. Libraries and browser apps should\nprefer the default import.\n\n## After editing lexicon JSON\n\nGenerated TypeScript is not rebuilt automatically. Regenerate before building\nor testing anything downstream — a stale tree is by far the most common cause\nof \"type X is not assignable to Y\" right after a lexicon edit:\n\n```bash\npnpm codegen        # repo root: every codegen:* script in the workspace\npnpm run prebuild   # inside one package\n```\n\n`pnpm codegen` builds `@atproto/lex-builder` first via `build:tooling`. If\ncodegen output still looks stale, that build is the thing to re-run.\n\nAdding a new external NSID means listing it in the package's `lexicons.json`\n`lexicons` array (or `pnpm lex:install <nsid>`), then re-running codegen. A new\ncanonical (`com.atproto.*` / `tools.ozone.*`) schema is added at the repo root\nand picked up by each dependent package's next `lex:install`; run\n`pnpm lex:update-local` from the root to propagate a changed canonical CID into\nevery manifest.\n\n## Network-installed schemas (outside this repo)\n\nFor a package whose Lexicons are _not_ maintained alongside it, `lex install`\nfetches them from the network into `./lexicons/` and records each resolution\n(AT URI + CID) in `./lexicons.json`.\n\n```bash\nlex install app.bsky.feed.post   # add NSIDs; dependencies resolve automatically\nlex install                      # install everything the manifest lists\nlex install --update             # re-resolve all to latest\nlex install --ci                 # fail if installed files drift from manifest CIDs\nlex install --no-save <nsid>     # install without touching the manifest\n```\n\nFlags: `--manifest <path>` (default `./lexicons.json`), `--lexicons <dir>`\n(default `./lexicons`), `--update`, `--ci`, `--no-save`.\n\nCommit both the manifest and the fetched JSON — they are the input contract,\nand the recorded CIDs are what makes `--ci` meaningful. The upstream-recommended\nwiring adds `\"postinstall\": \"lex install --ci\"` to catch drift on every install\nand `\"update-lexicons\": \"lex install --update\"` as the deliberate refresh.\n\nDon't commit a second **copy** of schemas that already live in\n[lexicons/](../../../lexicons/) — it would silently diverge. In-repo, vendor by\n**symlink** instead (next section), which can't drift.\n\n## Vendoring the canonical lexicons (in-repo)\n\nConsuming [lexicons/](../../../lexicons/) through `lex install` (rather than\ncompiling it directly) is the standard setup — it makes a package's lexicon set\nan explicit, CID-locked contract. A `directory` resolver resolves each canonical\nNSID from the root; those resolutions are **symlinked** into `./lexicons/` (not\ncopied) and locked with a `file://` URI relative to the manifest, so they can't\ndiverge. [packages/pds](../../../packages/pds/package.json) is the reference:\n\n```jsonc\n// lexicons.json (committed) — roots + resolvers + CID locks\n{\n  \"version\": 2,\n  \"lexicons\": [\"com.atproto.repo.getRecord\", \"…every vendored NSID…\"],\n  \"resolvers\": [\n    {\n      \"type\": \"directory\",\n      \"path\": \"../../lexicons\",\n      \"include\": [\"com.atproto.*\", \"tools.ozone.*\"],\n    },\n  ],\n  \"resolutions\": {\n    \"com.atproto.repo.getRecord\": {\n      \"uri\": \"file://../../lexicons/com/atproto/repo/getRecord.json\",\n      \"cid\": \"…\",\n    },\n  },\n}\n```\n\n```jsonc\n// package.json — install (creating symlinks + verifying the lock), then build\n\"codegen:lex\": \"pnpm run lex:install --ci\",\n\"lex:install\": \"lex install\",     // regenerate the manifest after adding/removing a lexicon\n\"postlex:install\": \"pnpm run lex:build\",\n\"lex:build\": \"lex build --clear --indexFile\",\n```\n\nGitignore the symlinked canonical dirs under `/lexicons` (`com/atproto`,\n`tools/ozone` — build artifacts) but **commit `lexicons.json`** (the contract),\nalong with any network- or GitHub-resolved copies. Generate/refresh the manifest with\n`pnpm run lex:install`; `--ci` then fails the build if `/lexicons` drifts from\nthe locked CIDs. Tradeoff: the `lexicons` roots list is explicit, so adding a\nnew canonical schema requires re-running `lex:install` to include it — unlike a\nplain `lex build --lexicons ../../lexicons`, which picks up every file\nimplicitly. For the full resolver vocabulary (`directory` / `http` / `repo`,\nordering, and the `lexicons-overrides` directory) see\n[Manifest resolvers](#manifest-resolvers-lexiconsjson) above.\n\n## Removing legacy `@atproto/lex-cli` codegen\n\nThe legacy output directory depends on which generator ran — `lex gen-server`\nemits `./src/lexicon` (singular), `lex gen-api` emits `./src/client`. Both are\nfully generated and safe to delete. Leave `lexicons/` alone; it is input, not\noutput.\n\nThen drop `@atproto/lex-cli` from `devDependencies`, add a `lexicons.json`\n(resolving the canonical set from the root plus whatever external namespaces the\npackage used), replace the old `codegen:lex` with the `lex:install` / `lex:build`\nform above, and repoint `.gitignore` at `src/lexicons` plus the symlinked\ncanonical dirs under `/lexicons`. (`packages/api` is the exception — it keeps `gen-api` but now\nfeeds it a locally-installed `./lexicons`; see\n[Which `lex` binary you get](#which-lex-binary-you-get).)\n\n## Related skills\n\nOnce the package generates code: [lex-schema](../lex-schema/SKILL.md)\n(`$`-accessors, validation), [lex-data](../lex-data/SKILL.md) (values, blobs,\nCBOR, branded strings), [lex-client](../lex-client/SKILL.md) (calling out),\n[xrpc-server](../xrpc-server/SKILL.md) (defining routes), and\n[lexification-client](../lexification-client/SKILL.md) /\n[lexification-server](../lexification-server/SKILL.md) (migrating off the\nlegacy stack).\n\n<!-- File: .agents/skills/lexification-client/SKILL.md (Tokens: ~4148 | Category: anthropic-skill) -->\n---\nname: lexification-client\ndescription: >\n  Migrate AT Protocol client and consumer code off the legacy `@atproto/api`,\n  `@atproto/lexicon`, and `@atproto/xrpc` stack onto `@atproto/lex`. Use\n  whenever code that *calls out* to an XRPC service is being modernized —\n  replacing an `AtpAgent` with `Client`, `result.data` / `result.headers[…]`\n  response handling, `XRPCError` try/catch, `jsonStringToLex` / `stringifyLex`,\n  `BlobRef` `instanceof` checks, `CID` from `multiformats`,\n  `new Date().toISOString()` on AT Proto datetimes, bare `string` DIDs and\n  handles, or legacy `isX()` type guards — including when the request is\n  phrased as \"clean this up\" or \"modernize this file\" rather than \"migrate\".\n  For code that *defines* routes, use the lexification-server skill.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating client / consumer code\n\nScope: **calls out**. Anything that used `AtpAgent` or the `@atproto/lexicon` /\n`@atproto/xrpc` runtime to talk to an AT Proto service, plus the data types\nthat flow across that boundary.\n\nRoute _definitions_ (`server.add`, handler signatures, codegen removal) are the\n[lexification-server skill](../lexification-server/SKILL.md). A service package\nalmost always needs both: it defines routes **and** calls other services. If\nyou're mid-migration on a service and reach an `agent.api.…` call, you're in\nthe right file.\n\n## What changes\n\n| Old                                               | New                                                                    |\n| ------------------------------------------------- | ---------------------------------------------------------------------- |\n| `AtpAgent` from `@atproto/api`                    | `Client` from `@atproto/lex`                                           |\n| `agent.api.<ns>.<method>(params, { headers })`    | `client.call(<schema>, params, opts)` or `client.xrpc(<schema>, opts)` |\n| `result.data`                                     | `result.body`                                                          |\n| `result.headers['content-language']`              | `result.headers.get('content-language')` (standard `Headers`)          |\n| `XRPCError` from `@atproto/xrpc`                  | `XrpcError` from `@atproto/lex`                                        |\n| try/catch + `instanceof XRPCError`                | `xrpcSafe()` / `client.xrpcSafe()` discriminated result                |\n| `HeadersMap` from `@atproto/xrpc`                 | `Headers as HeadersMap` from `@atproto/xrpc-server`                    |\n| `jsonStringToLex` / `stringifyLex`                | `lexParse` / `lexStringify`                                            |\n| `BlobRef` class from `@atproto/lexicon`           | `BlobRef` interface from `@atproto/lex-data` (or `@atproto/lex`)       |\n| `CID` from `multiformats/cid`                     | `Cid` / `parseCid` from `@atproto/lex`                                 |\n| `new Date().toISOString()` for AT Proto datetimes | `currentDatetimeString()` / `toDatetimeString()`                       |\n| `isX(v)` from `src/lexicon/types/…`               | `<ns>.<def>.$matches(v)` / `.$isTypeOf(v)`                             |\n\n## XRPC calls\n\nThree shapes, in rough order of preference — pick the least machinery that\ncovers what the call site actually uses.\n\n### `call()` — you only want the body\n\nErrors propagate as exceptions. Params (query) or body (procedure) go in the\nsecond argument; everything else in the third.\n\n```ts\nreturn ctx.suggestionsClient.call(\n  app.bsky.unspecced.getSuggestedUsersSkeleton,\n  { limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined },\n  { headers: params.headers },\n)\n```\n\n### `xrpc()` — you need status or headers too\n\n```diff\n- const res = await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton(\n-   { viewer: params.hydrateCtx.viewer, relativeToDid },\n-   { headers: params.headers },\n- )\n- return {\n-   suggestedDids: res.data.actors.map((a) => a.did),\n-   resHeaders: res.headers,\n- }\n+ const res = await ctx.suggestionsClient.xrpc(\n+   app.bsky.unspecced.getSuggestionsSkeleton,\n+   {\n+     params: { viewer: params.hydrateCtx.viewer, relativeToDid },\n+     headers: params.headers,\n+   },\n+ )\n+ const contentLang = res.headers.get('content-language')\n+ return {\n+   suggestedDids: res.body.actors.map((a) => a.did),\n+   resHeaders: contentLang ? { 'content-language': contentLang } : undefined,\n+ }\n```\n\nEverything is one options object: query params under `params`, procedure\nbodies under `body` (not `input`), plus `headers`, `signal`, `service`,\n`labelers`, `maxRetries`, and the validation flags.\n\nResponse headers are a standard `Headers` instance. If the value flows into\nsomething typed `HeadersMap` (`Record<string, string>`), pull the specific\nheaders out with `.get()` as above; if the consumer takes `Headers`, pass it\nthrough unchanged.\n\n### `xrpcSafe()` — errors are part of the contract\n\nReturns a discriminated result instead of throwing, and the failure is typed\nagainst the method schema. Use it where the call site already had a `catch`\nthat inspected the error rather than just rethrowing.\n\nNarrow on the concrete failure class when the branches map to different\ndownstream responses (see `packages/bsky/src/api/app/bsky/feed/getFeed.ts`):\n\n```ts\nimport {\n  XrpcInvalidResponseError,\n  XrpcResponseError,\n  xrpcSafe,\n} from '@atproto/lex'\n\nconst result = await xrpcSafe(fgEndpoint, app.bsky.feed.getFeedSkeleton, {\n  strictResponseProcessing: false,\n  signal: AbortSignal.timeout(10_000),\n  headers,\n  params: { feed, limit, cursor },\n})\n\nif (!result.success) {\n  const cause = result.reason\n\n  // Structurally valid XRPC error (4xx/5xx) — pass it through\n  if (cause instanceof XrpcResponseError) {\n    const { status, body } = cause.toDownstreamError()\n    throw new XRPCError(status, body.message, body.error, { cause })\n  }\n\n  // Response didn't match the schema\n  if (cause instanceof XrpcInvalidResponseError) {\n    throw new UpstreamFailureError(\n      'feed provided an invalid response',\n      'InvalidFeedResponse',\n      { cause },\n    )\n  }\n\n  // Typically a network error\n  throw new UpstreamFailureError('feed unavailable', undefined, { cause })\n}\n\nconst { feed: feedSkele, cursor } = result.body // typed\n```\n\n`toDownstreamError()` is what replaces hand-mapping upstream status codes: it\nremaps 500 → 502 and strips hop-by-hop headers. It does **not** redact — when\nthe upstream payload is a structurally valid XRPC error, its `error` / `message`\nare passed through verbatim for transparency. Sanitize them yourself before\nre-throwing if the upstream may leak internal detail.\n\nWhen you only care about one schema-declared error code, skip the class checks:\n\n```ts\nconst res = await xrpcSafe(pds, com.atproto.sync.getLatestCommit, {\n  params: { did },\n})\nif (res.success) return true\nif (res.error === 'RepoNotFound') return false\nthrow res.reason\n```\n\n`result.matchesSchemaErrors()` narrows `result.error` to the codes the method\ndeclares, for when you want the compiler to check the code strings.\n\nThe old `ResponseType` enum comparisons have no replacement and don't need one:\n`XrpcInvalidResponseError` covers `ResponseType.InvalidResponse`, and\n`XrpcInternalError` / `XrpcFetchError` cover `ResponseType.Unknown`.\n\nRetries are built in (`maxRetries`, default `0`), so delete any hand-rolled\nretry loop wrapped around the call. See [lex-client](../lex-client/SKILL.md)\nfor the full error hierarchy and client API.\n\n## Agent → Client\n\n```diff\n- import { AtpAgent } from '@atproto/api'\n+ import { Client } from '@atproto/lex'\n\n- searchAgent: AtpAgent | undefined\n+ searchClient: Client | undefined\n```\n\n`Client` takes **two** arguments: the agent (a service URL, an `AgentConfig`,\nor a session), then per-client options. Static headers belong in the agent\nconfig rather than an imperative `setHeader` after construction — one frozen\nconfig then describes the whole client (see `packages/bsky/src/index.ts`):\n\n```diff\n- const myServiceAgent = config.serviceUrl\n-   ? new AtpAgent({ service: config.serviceUrl })\n-   : undefined\n- if (myServiceAgent && config.serviceApiKey) {\n-   myServiceAgent.api.setHeader('authorization', `Bearer ${config.serviceApiKey}`)\n- }\n+ const myServiceClient = config.serviceUrl\n+   ? new Client(\n+       {\n+         service: config.serviceUrl,\n+         headers: config.serviceApiKey\n+           ? { authorization: `Bearer ${config.serviceApiKey}` }\n+           : undefined,\n+       },\n+       {\n+         // Trust internal services to send us well-formed responses\n+         strictResponseProcessing: false,\n+         validateResponse: config.debugMode,\n+       },\n+     )\n+   : undefined\n```\n\nThat second argument is worth setting for internal service-to-service clients:\n`Client` validates responses by default, which is stricter than `AtpAgent` was,\nso a migrated call can start failing on data the old code happily accepted.\n\n## Errors\n\n```diff\n- import { XRPCError } from '@atproto/xrpc'\n+ import { XrpcError } from '@atproto/lex'\n```\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for **throwing** errors out of\nhandlers — that one is unrelated and stays. A file can legitimately import\nboth (getFeed.ts above does): `XrpcError` describes a failed call out,\n`XRPCError` produces the response going back down.\n\n## Headers map type\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import type { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAlias it — the unaliased `Headers` shadows the global `Headers` type, which the\nsame file usually also needs.\n\n## Data utilities\n\n### JSON ↔ Lex\n\n```diff\n- import { jsonStringToLex } from '@atproto/lexicon'\n+ import { lexParse } from '@atproto/lex'\n\n- const parsed = jsonStringToLex(payload.toString('utf8')) as SubjectActivitySubscription\n+ const parsed = lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(\n+   payload.toString('utf8'),\n+ )\n```\n\n`lexParse` takes a type parameter, so the `as` cast goes away. `stringifyLex` →\n`lexStringify`. See [lex-data](../lex-data/SKILL.md) for `jsonToLex`,\n`lexToJson`, `lexParseJsonBytes`, `parseLexLink`, `parseLexBytes`.\n\n### Datetime strings\n\n```diff\n- createdAt: new Date().toISOString(),\n+ createdAt: currentDatetimeString(),\n\n- indexedAt: someDate.toISOString(),\n+ indexedAt: toDatetimeString(someDate),\n```\n\nBoth come from `@atproto/lex` and return the branded `DatetimeString`, so a\nvalue that reaches a lexicon field is checked at the type level rather than at\nvalidation time.\n\n### CIDs\n\n```diff\n- import { CID } from 'multiformats/cid'\n+ import { type Cid, parseCid } from '@atproto/lex'\n```\n\n`Cid` is an interface. Drop `multiformats` from the package's dependencies once\nno imports remain.\n\n### BlobRef\n\n`BlobRef` is now an interface (a union of `TypedBlobRef` and `LegacyBlobRef`),\nso `instanceof` no longer works — and the hand-written fallbacks that used to\npaper over the two JSON shapes are exactly what the helpers replace:\n\n```diff\n- import { BlobRef } from '@atproto/lexicon'\n+ import { type BlobRef, getBlobCidString } from '@atproto/lex-data'\n\n- export const cidFromBlobJson = (json: BlobRef) => {\n-   if (json instanceof BlobRef) return json.ref.toString()\n-   if (json['$type'] === 'blob') return (json['ref']?.['$link'] ?? '') as string\n-   return (json['cid'] ?? '') as string\n- }\n+ export const cidFromBlobJson = (json: BlobRef): string => getBlobCidString(json)\n```\n\n```diff\n- if (value instanceof BlobRef) { ... }\n+ if (isBlobRef(value)) { ... }\n```\n\n`isBlobRef` validates the CID strictly by default; pass `{ strict: false }` for\nlegacy data. See [lex-data](../lex-data/SKILL.md) for the\n`TypedBlobRef` / `LegacyBlobRef` split.\n\n## Branded types at boundaries\n\nApply `DidString`, `HandleString`, `AtUriString`, `DatetimeString`, `Cid` at\nsignatures, interface fields, and DB schema types — that's where they stop bad\nvalues propagating. Prefer importing them from `@atproto/lex` over\n`@atproto/syntax`.\n\n```diff\n- did: string\n+ did: DidString\n\n- iss: string\n+ iss: DidString | `${DidString}#${string}`\n```\n\nPrefix checks become guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nData arriving from protobuf, the data plane, or Kysely is plain `string`. Cast\nonce at that entry point rather than asserting at every later use:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n```\n\n## Type guards\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { ... }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { ... }\n```\n\n`$isTypeOf` is the behavioral equivalent of a legacy `isX()` guard, so it is the\ndefault when replacing one — this is what the repo migrated to (see\n`packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts`). It checks only\nthe `$type` tag, which is all that matters on an already-validated union, and it\ndoubles as a predicate:\n\n```diff\n- const pref = prefs.find((p) => p.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ const pref = prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` instead only where the data is genuinely untrusted and a\n`$type` tag alone isn't enough of a guarantee — it validates the whole value, so\nit costs more. Treat that as a deliberate upgrade at an entry point, not the\ndefault swap for an `isX()` call.\n\nSee [lex-schema](../lex-schema/SKILL.md) for the full `$`-accessor cheat sheet.\n\n## Tests\n\nTest migration is partial and lags the source migration. The `pds`, `bsky` and\n`ozone` suites mostly still drive `AtpAgent` from `@atproto/api`, and `dev-env`\nexposes both `getAgent(): AtpAgent` and `getClient(): Client` for exactly that\nreason.\n\nA suite still on `AtpAgent` hydrates blobs into **legacy `BlobRef` class\ninstances**, which only `@atproto/lexicon`'s `lexToJson` knows how to flatten.\nSwapping a test helper to `@atproto/lex`'s `lexToJson` leaves those instances\nuntouched and leaks their internal `original` field into snapshots — which reads\nlike a wire-format regression in the service under test. Keep the legacy\nconverter until the suite itself moves to `Client`.\n\nDefault to leaving passing tests alone: during a source migration their value\nis being an unchanged runtime regression check. Two things force a change:\n\n- **A test imports a legacy path you deleted** (`src/lexicon/…`). Point it at\n  the `@atproto/api` equivalent (`ComAtprotoAdminDefs`, `ids`, `$Typed`) — the\n  smallest edit that keeps the test running.\n- **You are deliberately migrating that test file.** Then it moves wholesale:\n  `network.pds.getClient()`, schemas from `src/lexicons/index.js`, `res.body`,\n  and helpers like `getBlobCidString` from `@atproto/lex-data`.\n  `packages/pds/tests/file-uploads.test.ts` is a migrated example.\n\nAvoid half-migrating a file — a suite mixing `agent.api.…` and `client.call(…)`\nfor the same operation is harder to read than either end state.\n\n## Import source summary\n\n| Before                                            | After                                            |\n| ------------------------------------------------- | ------------------------------------------------ |\n| `@atproto/api` (`AtpAgent`)                       | `@atproto/lex` (`Client`)                        |\n| `@atproto/lexicon` (`jsonStringToLex`, `BlobRef`) | `@atproto/lex` (`lexParse`, `BlobRef`)           |\n| `@atproto/lexicon` (`stringifyLex`)               | `@atproto/lex` (`lexStringify`)                  |\n| `@atproto/xrpc` (`XRPCError`)                     | `@atproto/lex` (`XrpcError`, `xrpcSafe`)         |\n| `@atproto/xrpc` (`HeadersMap`)                    | `@atproto/xrpc-server` (`Headers as HeadersMap`) |\n| `multiformats/cid` (`CID`)                        | `@atproto/lex` (`Cid`, `parseCid`)               |\n| `@atproto/syntax` (`DidString`, etc.)             | `@atproto/lex` (prefer this)                     |\n\nDropped imports usually mean dropped `package.json` dependencies\n(`multiformats`, `@atproto/lexicon`, `@atproto/xrpc`), and every package\ntouched needs a changeset entry.\n\n## Related skills\n\n[lex-client](../lex-client/SKILL.md) for the full `Client` API you're migrating\nonto, [lex-setup](../lex-setup/SKILL.md) for codegen and dependency wiring, and\n[lexification-server](../lexification-server/SKILL.md) for the route-definition\nhalf — service packages need both.\n\n<!-- File: .agents/skills/lexification-server/references/project-config.md (Tokens: ~991 | Category: anthropic-skill) -->\n# Project configuration\n\nRead this for step 1 of the migration: replacing `lex gen-server` codegen with\n`lex build`, fixing dependencies, and deleting the legacy generated tree.\n\n`packages/ozone/package.json` is the current \"before\"; `packages/pds` and\n`packages/bsky` are the \"after\".\n\n## Scripts\n\nBefore (ozone — note the hand-maintained glob list and the staleness check):\n\n```jsonc\n\"codegen:lex\": \"lex gen-server --yes ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/*\",\n\"prebuild\": \"[ -f ./src/lexicon/index.ts ] && [ -z \\\"$(find ../../lexicons -newer ./src/lexicon/index.ts -type f -print -quit)\\\" ] || pnpm run codegen:lex\",\n```\n\nAfter:\n\n```jsonc\n\"codegen:lex\": \"lex build --clear --indexFile --lexicons ../../lexicons\",\n\"prebuild\": \"pnpm run '/^(codegen:.+)$/'\",\n```\n\nTwo things fall out of this, both deliberate:\n\n- No NSID list. `lex build` compiles the whole `lexicons/` tree, so adding a\n  lexicon needs no `package.json` edit. (Small libraries use `--include` to\n  trim output; a service that serves most of a namespace should not.)\n- No staleness guard. `--clear` regenerates unconditionally and the glob\n  `prebuild` picks up every `codegen:*` script the package declares, so a\n  package that also generates protobuf (`bsky`) or templates (`pds`) needs no\n  change to that line. Keep the name `codegen:lex` — root `pnpm codegen` runs\n  `/^(codegen:.+)$/` across the workspace, and a generator hidden inside\n  `prebuild` would be skipped by it.\n\nFull flag reference: [lex-setup skill](../../lex-setup/SKILL.md).\n\n## Dependencies\n\nAdd to `dependencies`:\n\n- `@atproto/lex`\n- `@atproto/xrpc-server` (already present in every service — verify, don't add\n  blindly)\n\nRemove:\n\n- `@atproto/lexicon` — `dependencies`\n- `@atproto/xrpc` — `dependencies`\n- `multiformats` — `dependencies`; CIDs come from `@atproto/lex-data` via\n  `@atproto/lex`\n- `@atproto/lex-cli` — `devDependencies`\n\nAdd the `@atproto/lex-*` sub-packages only when a file imports them directly.\n`pds` lists `lex-cbor`, `lex-data`, and `lex-json` in `dependencies` and\n`lex-document` in `devDependencies` for exactly that reason; `bsky` needs none\nof them and imports everything through the umbrella.\n\nKeep `@atproto/api` only where it is genuinely still used. It stays a\ndevDependency of `pds` (tests) and a runtime dependency of `bsky` (two source\nfiles import `getAgeAssuranceRegionConfig` and age-assurance helpers from it).\n\nEvery package touched needs a `.changeset/` entry — `minor` for a package\nwhose public API changes, `patch` otherwise.\n\n## Gitignore\n\nGenerated output is a build artifact:\n\n```\n# @atproto/lex\nsrc/lexicons\n```\n\nReplaces the `# @atproto/lex-cli` / `src/lexicon` stanza. Note singular →\nplural: `src/lexicon` was the legacy path, `src/lexicons` is the new one. Both\nlines are unanchored patterns, not `/src/lexicons/`.\n\n## Deleting the legacy tree\n\n```sh\nrm -rf ./src/lexicon\n```\n\nSafe because it was gitignored and regenerated. Verify nothing still imports\nit before deleting — in ozone that is ~76 source files plus ~47 test files, so\nexpect the package not to compile until step 3 is done.\n\nDo **not** touch the repo-root `lexicons/` directory. It is canonical\ncommitted source shared by every package and the interop fixtures.\n\n## What does not apply in this monorepo\n\n`lex install`, `manifest.json` / `lexicons.json`, and a `postinstall` hook are\nreal `@atproto/lex` features for consuming schemas published over the network.\nNo package here uses them, because the schemas are committed at the repo root.\nDo not introduce a manifest during a migration.\n\n<!-- File: .agents/skills/lexification-server/references/routes.md (Tokens: ~1550 | Category: anthropic-skill) -->\n# Migrating route files\n\nRead this for step 3: converting `server.<ns>.<method>(…)` registrations to\n`server.add(schema, …)`, and moving parameter/output types off\n`../lexicon/types/…`.\n\nFor the target API in full — auth verifiers, rate limits, pipethrough, error\nclasses — see the [xrpc-server skill](../../xrpc-server/SKILL.md). This file\ncovers only what the _migration_ changes.\n\n## Registration\n\nThe namespace chain becomes a schema argument. Everything inside the config\nobject is unchanged:\n\n```diff\n- server.app.bsky.feed.getAuthorFeed({\n+ server.add(app.bsky.feed.getAuthorFeed, {\n    auth: ctx.authVerifier.optionalStandardOrRole,\n    handler: async ({ params, auth, req }) => { /* unchanged */ },\n  })\n```\n\nThe bare-handler form drops the wrapper object:\n\n```diff\n- server.com.atproto.identity.resolveHandle(async ({ req, params }) => {\n+ server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n```\n\nThe schema always comes from the generated tree, imported at the top of the\nfile alongside `Server`:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../../lexicons/index.js'\n```\n\nMigrated code overwhelmingly uses the object form (~187 registrations in\n`pds` + `bsky`, versus ~8 bare-handler). Preserve whichever form the file\nalready had; there is no reason to convert between them during a migration.\n\n## `encoding` and literal widening\n\nThe form of the route — bare handler vs `{ handler }` object — is not what\ndecides this. What matters is whether the returned literal is still\n_contextually typed_ by `server.add()`. Returning it directly from a handler\nthat declares at least one parameter is, in both forms, so `as const` is\nunnecessary. Contextual typing is lost when the response is built into an\nintermediate variable first, or when the handler declares no parameters at all\n(`async () => ({ … })`); then `encoding` widens to `string` and the overload\nfails with `TS2769: No overload matches this call`.\n\n```ts\n// contextually typed — `as const` unnecessary in either form\nserver.add(app.bsky.graph.getRelationships, {\n  handler: async ({ params }) => {\n    return { encoding: 'application/json', body: { actor, relationships } }\n  },\n})\n\n// also contextually typed; the `as const` here is redundant but harmless,\n// and is what the real resolveHandle route still carries\nserver.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n  return { encoding: 'application/json' as const, body: { did } }\n})\n```\n\nMany existing routes carry `as const` where it is not strictly needed; it is\nharmless, so leave it alone rather than churning call sites. Full rules live in\nthe [xrpc-server skill](../../xrpc-server/SKILL.md).\n\n`satisfies …$Output` on the returned envelope also pins the literal and works\nin either form, but it is verbose and the repo does not use it. If a return\nneeds an explicit contract, the one real precedent annotates the _body_:\n\n```ts\nbody: { /* … */ } satisfies app.bsky.unspecced.getConfig.$OutputBody,\n```\n\n(`$Output` is the whole `{ encoding, body }` envelope; `$OutputBody` is just\nthe body. Mixing them up produces a confusing excess-property error.)\n\n## Type-only references\n\n```diff\n- import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed'\n+ import { app } from '../../../../lexicons/index.js'\n- type Params = QueryParams\n+ type Params = app.bsky.feed.getAuthorFeed.$Params\n```\n\n| Legacy generated type                | Accessor                                    |\n| ------------------------------------ | ------------------------------------------- |\n| `QueryParams` / `InputSchema` params | `$Params`                                   |\n| `OutputSchema`                       | `$OutputBody`                               |\n| whole handler output                 | `$Output`                                   |\n| `InputSchema` (procedures)           | `$Input`                                    |\n| subscription message union           | `$Message`                                  |\n| record / main object                 | `.Main`                                     |\n| sub-definitions                      | their own name (`.ReplyRef`, `.ViewRecord`) |\n\nBecause the namespace object carries both values and types, one\n`import { app } from '../lexicons/index.js'` replaces every per-method type\nimport in the file. Use `import type` when the file needs only types.\n\n## `$lxm` in auth and proxy code\n\nMethod-id strings that used to come from `ids`:\n\n```diff\n- const aud = computeProxyTo(ctx, req, ids.AppBskyActorGetPreferences)\n+ const aud = computeProxyTo(ctx, req, app.bsky.actor.getPreferences.$lxm)\n```\n\nInside a handler where only the schema object is in scope, `l.getMain(ns)`\nreaches the underlying method definition (`.output.schema`, `.$type`) — used by\n`pds`'s read-after-write helpers and `bsky`'s data-plane record routes.\n\n## Subscriptions\n\nRegister the same way; the handler is an async generator and messages are\nbuilt with `$build`:\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    yield com.atproto.sync.subscribeRepos.commit.$build({ seq, time, ...evt })\n  },\n)\n```\n\n`signal` is an `AbortSignal` on the subscription context — pass it to whatever\nproduces events so client disconnects unwind the generator. See\n[packages/pds/src/api/com/atproto/sync/subscribeRepos.ts](../../../../packages/pds/src/api/com/atproto/sync/subscribeRepos.ts).\n\n## Errors thrown by handlers\n\nNothing to do. `InvalidRequestError`, `AuthRequiredError`, `ForbiddenError`,\n`InternalServerError`, `UpstreamFailureError`, `UpstreamTimeoutError`,\n`NotEnoughResourcesError`, and `MethodNotImplementedError` already come from\n`@atproto/xrpc-server` in unmigrated packages — the legacy generated tree never\nre-exported them. Leave those imports alone.\n\nWhat does change is errors the service _catches_ from its own outbound calls —\nthose move from `@atproto/xrpc`'s `XRPCError` to `XrpcError` from\n`@atproto/lex`. `XrpcError` is abstract: catch and narrow it, never construct\nit. That half belongs to\n[lexification-client](../../lexification-client/SKILL.md).\n\n## Header maps\n\n```diff\n- import { HeadersMap } from '@atproto/xrpc'\n+ import { Headers as HeadersMap } from '@atproto/xrpc-server'\n```\n\nAliased because `Headers` collides with the DOM global. The types are not\nidentical: legacy `HeadersMap` was `Record<string, string | undefined>`, the\nnew `Headers` is `Record<string, string>`. Code that relied on assigning\n`undefined` to clear a header needs the key omitted instead.\n\n<!-- File: .agents/skills/lexification-server/references/types-and-guards.md (Tokens: ~1739 | Category: anthropic-skill) -->\n# Constants, guards, and branded types\n\nRead this for steps 4 and 6: replacing `ids.*`, the generated `isX()` guards,\ntoken constants, and hand-written `$type` literals; then tightening string\ntypes at boundaries.\n\n## `ids.*` → `$type` / `$lxm`\n\nThe `ids` object is gone. Which accessor replaces it depends on what the\nstring is _for_:\n\n```diff\n- import { ids } from '../../../lexicon/lexicons'\n- if (uri.collection === ids.AppBskyGraphList) {\n+ import { app } from '../../../lexicons/index.js'\n+ if (uri.collection === app.bsky.graph.list.$type) {\n```\n\n```diff\n- method === ids.AppBskyFeedGetFeedSkeleton\n+ method === app.bsky.feed.getFeedSkeleton.$lxm\n```\n\n- `$type` — record and object type strings (collections, union tags).\n- `$lxm` — XRPC method ids (auth scopes, proxy targets).\n- `$nsid` — the raw NSID; the fallback for `defs` documents that define no\n  `main` type and therefore have no `$type`.\n\nUse the accessor, not a string literal. A literal is untracked when an NSID is\nrenamed and invisible to the tree-shaking that keeps generated output small.\n\n## `isX()` → `$isTypeOf` or `$matches`\n\nLegacy codegen emitted one `isX()` per definition that checked only the\n`$type` field. The new tree splits that into two accessors, and choosing\ncorrectly matters:\n\n- **`$isTypeOf`** — reads the `$type` tag only. A type predicate, so TS\n  narrows automatically inside `.find()` / `.filter()` / `if`. Correct for\n  discriminating a union whose members were already validated (schema output,\n  hydrated views, anything that came through `$parse`).\n- **`$matches`** — validates the entire value against the schema. Correct\n  when the data's shape is not yet guaranteed: raw records off the firehose,\n  values out of the datastore, third-party input.\n\n`$isTypeOf` is the direct behavioral equivalent of the old `isX()`, and it is\nwhat the real migration used:\n\n```diff\n- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs'\n- if (isRepoRef(subject)) { … }\n+ if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { … }\n```\n\n```diff\n- repost: isSkeletonReasonRepost(item.reason) ? … : undefined,\n+ repost: app.bsky.feed.defs.skeletonReasonRepost.$isTypeOf(item.reason) ? … : undefined,\n```\n\nHand-written `$type` comparisons collapse the same way:\n\n```diff\n- prefs.find((pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref')\n+ prefs.find(app.bsky.actor.defs.personalDetailsPref.$isTypeOf)\n```\n\nReach for `$matches` deliberately, at the point where untrusted data enters —\nin `bsky` that is the record indexing plugins and hydration, in `pds` it is\nre-reading a stored record. Both packages use it a dozen times total against\n~50 uses of `$isTypeOf`.\n\nValidation that returns a result rather than a boolean is `$safeValidate` (or\n`$validate` to throw). See [lex-schema](../../lex-schema/SKILL.md).\n\n## Token values\n\n```diff\n- import { CURATELIST, MODLIST } from '../../../../lexicon/types/app/bsky/graph/defs'\n+ import { app } from '../../../../lexicons/index.js'\n+ const CURATELIST = app.bsky.graph.defs.curatelist.$token\n+ const MODLIST = app.bsky.graph.defs.modlist.$token\n```\n\nUse `$token`, per [STYLE_GUIDE.md](../../../../STYLE_GUIDE.md). It reads as one\nof the `$`-prefixed schema constants (`$type`, `$lxm`, `$nsid`) rather than as a\nfield on the value. Some older call sites use `.value`, which resolves to\nthe same string — replace them if you are editing that file.\n\n## `$build()` for `$type`-carrying values\n\nObject literals that hand-wrote a `$type` field:\n\n```diff\n- return {\n-   $type: 'app.bsky.graph.defs#relationship',\n-   did,\n-   following: subject.following,\n- }\n+ return app.bsky.graph.defs.relationship.$build({\n+   did,\n+   following: subject.following,\n+ })\n```\n\n`$build` fills in `$type` and types the result, so a renamed definition\nbecomes a compile error instead of a silently wrong tag. It is also how\nsubscription messages are emitted (see\n[routes.md](routes.md#subscriptions)).\n\nValues that carry no `$type` need no builder — a type annotation is enough,\nand that is what most def usages become:\n\n```ts\ntype CodeDetail = com.atproto.server.defs.InviteCode\n```\n\n## Centralized type-alias files\n\nA package with a `types.ts` re-exporting from `src/lexicon/types/…` repoints\nit at the generated tree. `packages/bsky/src/views/types.ts` is the live\nexample; copy its conventions rather than inventing names:\n\n```ts\nimport { app, com } from '../lexicons/index.js'\n\nexport type ProfileRecord = app.bsky.actor.profile.Main\nexport const isProfileRecordType = app.bsky.actor.profile.$isTypeOf\n\nexport type StrongRef = com.atproto.repo.strongRef.Main\nexport const validateStrongRef = com.atproto.repo.strongRef.$safeValidate\n```\n\n- Types: `export type Foo = ns.path.TypeName` — `.Main` for the main\n  definition, the def's own name otherwise.\n- Guards: `export const isFooType = ns.path.$isTypeOf`. The `…Type` suffix is\n  intentional; it distinguishes a tag check from a full validation and avoids\n  colliding with the legacy `isFoo` names being replaced.\n- Validators: `export const validateFoo = ns.path.$safeValidate`.\n\nGrouping by NSID prefix with a comment header keeps a 200-line alias file\nnavigable.\n\n## Branded strings\n\nApply at type boundaries — function signatures, interface fields, DB row\ntypes — and leave runtime code alone:\n\n```diff\n- did: string                → did: DidString\n- handle: string             → handle: HandleString\n- handleOrDid: string        → handleOrDid: AtIdentifierString\n- indexedAt: string          → indexedAt: DatetimeString\n- iss: string                → iss: DidString | `${DidString}#${string}`\n```\n\nReplace prefix sniffing with the real guards:\n\n```diff\n- if (typeof iss !== 'string' || !iss.startsWith('did:')) {\n+ if (typeof iss !== 'string' || !isDidString(iss)) {\n```\n\nPrefer guards over assertion helpers: a guard lets the caller decide what a\nmalformed value means, while an assertion turns it into a 500.\n\nData crossing a boundary TypeScript can't see — protobuf messages, Kysely\nrows, `JSON.parse` — arrives as plain `string`. Cast once, at that entry\npoint:\n\n```diff\n- suggestedDids: dids,\n+ suggestedDids: dids as DidString[],\n\n- qb.where('actor.did', '=', filter.sub!)\n+ qb.where('actor.did', '=', filter.sub! as DidString)\n```\n\nCasting later, after the value has been passed around, means every\nintermediate signature stays `string` and the brand buys nothing.\n\nThese types are re-exported from `@atproto/syntax` unchanged, so\n`import { DidString } from '@atproto/lex'` and\n`from '@atproto/syntax'` are the same type — migrated files use both, and\nfiles already importing other things from `@atproto/syntax` keep doing so.\nFull list and semantics: [lex-data skill](../../lex-data/SKILL.md).\n\n<!-- File: .agents/skills/lexification-server/SKILL.md (Tokens: ~2412 | Category: anthropic-skill) -->\n---\nname: lexification-server\ndescription: >\n  Migrate a service package off the legacy server stack — `lex gen-server`\n  codegen, a committed `src/lexicon/` tree, `server.<ns>.<method>()` route\n  chains, `@atproto/api` inside service code — onto `@atproto/lex` and\n  `@atproto/xrpc-server`. Use when asked to lexify, modernize, or migrate a\n  server package; when a route file imports `Server` or `createServer` from\n  `../lexicon/index.js`; when replacing `ids.*` constants, generated `isX()`\n  guards, `server.xrpc.router`, or `QueryParams`/`OutputSchema` type imports;\n  and when a package still runs `lex gen-server` in its `codegen:lex` script.\n  For the calls a service makes *out* to other services, use\n  lexification-client. For writing new routes on an already-migrated package,\n  use xrpc-server.\ndisable-model-invocation: false\n---\n\n# Lexification: migrating a service package\n\nMoves a package from `@atproto/lex-cli`'s `lex gen-server` output to\n`@atproto/lex` schemas plus `@atproto/xrpc-server` routing.\n\nEvery service in this repo — `packages/pds`, `packages/bsky`, `packages/ozone` —\nis already migrated, so this skill now applies to packages outside the repo, or\nto a stray route chain left behind. **Read the migrated services before\ninventing a pattern.**\n\n## Which skill applies\n\n| Situation                                                    | Skill                                                  |\n| ------------------------------------------------------------ | ------------------------------------------------------ |\n| Package has `src/lexicon/` (singular) and route chains       | **this skill**                                         |\n| Adding a route to a package already on `server.add()`        | [xrpc-server](../xrpc-server/SKILL.md)                 |\n| The same package's outbound `AtpAgent` / `agent.api.…` calls | [lexification-client](../lexification-client/SKILL.md) |\n\nA service migration almost always needs this skill _and_\nlexification-client: one package both serves routes and calls other services.\nDo the server half first — it forces `src/lexicons/` into existence, which the\nclient half then imports from.\n\n## Guiding principles\n\n- **Type-level changes over runtime changes.** Working runtime logic stays;\n  what changes is where types and constants come from. A migration diff that\n  alters behavior is a bug in the migration.\n- **Migrate one namespace of route files at a time**, building in between.\n  A route file is self-contained, so a broken one doesn't block the rest.\n- **Leave test call sites on `AtpAgent`.** Tests double as the regression\n  check that the migration didn't change behavior — swapping the client they\n  use destroys that signal. Tests that import _types or constants_ from the\n  deleted `src/lexicon/` do have to move (details below).\n\n## Order of work\n\n1. **Project config** — swap codegen, deps, gitignore, delete `src/lexicon/`.\n   → [references/project-config.md](references/project-config.md)\n2. **Server bootstrap** — `createServer`, `Server` type, Express mount.\n3. **Route files** — `server.<ns>.<method>(…)` → `server.add(schema, …)`.\n   → [references/routes.md](references/routes.md)\n4. **Constants and guards** — `ids.*`, `isX()`, token exports, `$build`.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n5. **Outbound calls** — [lexification-client](../lexification-client/SKILL.md).\n6. **Branded types** at function/interface/DB boundaries.\n   → [references/types-and-guards.md](references/types-and-guards.md)\n\nSteps 1–2 are a single non-splittable commit (the package won't compile\nin between). Steps 3–6 are incremental.\n\n## Server bootstrap\n\nThe only structural change in the whole migration. `createServer` and `Server`\nstop coming from generated code:\n\n```diff\n- import { createServer } from './lexicon/index.js'\n+ import { createServer } from '@atproto/xrpc-server'\n\n- let server = createServer({\n+ const server = createServer([], {\n    validateResponse: config.debugMode,\n    payload: { /* … */ },\n  })\n\n- app.use(server.xrpc.router)\n+ app.use(server.router)\n```\n\nThe new first argument is an array of legacy `LexiconDoc`s for routes not\nbacked by a generated schema; schema-based setups pass `[]`. Option names\n(`validateResponse`, `payload`, `catchall`, `rateLimits`, `errorParser`) are\nunchanged — see [packages/pds/src/index.ts](../../../packages/pds/src/index.ts).\n\nIn route files, `Server` comes from the same package:\n\n```diff\n- import { Server } from '../../../../lexicon/index.js'\n+ import type { Server } from '@atproto/xrpc-server'\n```\n\n## Import mapping\n\n| Before                                                 | After                                                                |\n| ------------------------------------------------------ | -------------------------------------------------------------------- |\n| `../lexicon` (`Server`, `createServer`)                | `@atproto/xrpc-server`                                               |\n| `../lexicon/lexicons` (`ids`)                          | `../lexicons/index.js` → `.$type` / `.$lxm`                          |\n| `../lexicon/types/…` (record & def types)              | `../lexicons/index.js` → `.Main`, `.ViewRecord`, …                   |\n| `../lexicon/types/…` (`QueryParams`, `OutputSchema`)   | `../lexicons/index.js` → `.$Params`, `.$OutputBody`                  |\n| `../lexicon/types/…` (`isX`, `validateX`)              | `../lexicons/index.js` → `.$isTypeOf`, `.$matches`, `.$safeValidate` |\n| `../lexicon/util` (`$Typed`, `Un$Typed`)               | `@atproto/lex`                                                       |\n| `@atproto/api` (`AtpAgent`)                            | `@atproto/lex` (`Client`)                                            |\n| `@atproto/lexicon` (`jsonStringToLex`, `stringifyLex`) | `@atproto/lex` (`lexParse`, `lexStringify`)                          |\n| `@atproto/lexicon` (`BlobRef`)                         | `@atproto/lex` (`BlobRef`)                                           |\n| `@atproto/xrpc` (`HeadersMap`)                         | `@atproto/xrpc-server` (`Headers`, usually aliased)                  |\n| `@atproto/xrpc` (`XRPCError`, thrown by a _caller_)    | `@atproto/lex` (`XrpcError`)                                         |\n| `multiformats/cid` (`CID`)                             | `@atproto/lex` (`Cid`, `parseCid`)                                   |\n\n`@atproto/xrpc-server` keeps its own `XRPCError` for errors handlers **throw**.\nThe casing distinguishes them: `XRPCError` server-side (concrete, thrown),\n`XrpcError` client-side (abstract, caught). Both can appear in one file.\n\n`@atproto/lex` re-exports `lex-client`, `lex-schema`, `lex-data`, and\n`lex-json`, so `Cid`, `BlobRef`, `lexParse`, and `$Typed` all resolve from it.\n`@atproto/lex-cbor` is **not** re-exported. Importing from either the umbrella\nor the sub-package is fine; match whatever the file already does.\n\n## Pitfalls\n\n- **`$type` vs `$lxm` vs `$nsid`.** `$type` is the record/object type string\n  (`app.bsky.feed.post.$type` → `'app.bsky.feed.post'`), used for collections\n  and union tags. `$lxm` is the method id, used in auth and proxy checks.\n  `$nsid` is the raw NSID, needed for `defs` documents that have no `main`.\n  They can hold identical strings, so a wrong one type-checks and only fails\n  at runtime.\n- **`$matches` vs `$isTypeOf`.** `$isTypeOf` only reads the `$type` tag;\n  `$matches` validates the whole value. Substituting `$isTypeOf` where the\n  data is unvalidated silently accepts malformed records. See\n  [references/types-and-guards.md](references/types-and-guards.md).\n- **`encoding` widening** affects only the bare-handler form of `server.add`,\n  not the object form. Don't sprinkle `as const` everywhere — see\n  [references/routes.md](references/routes.md).\n- **Branded strings arriving from outside TypeScript's view** — protobuf,\n  Kysely rows, `JSON.parse` — are plain `string`. Cast once at the boundary\n  (`as DidString`), never deep in the call graph.\n- **Generated `src/lexicons/` is gitignored.** It won't exist until\n  `pnpm run codegen` (or a build) runs. \"Cannot find module '../lexicons/index.js'\"\n  means codegen hasn't run, not that the import is wrong.\n\n## Tests\n\nTest _call sites_ keep using `AtpAgent`, deliberately (see principles above).\n`dev-env` exposes both `getAgent(): AtpAgent` and `getClient(): Client`, so a\nnew-style client is available when a test genuinely needs one.\n\nWhat must move is anything a test imported from the now-deleted\n`src/lexicon/`. Two valid destinations, both already used in migrated\npackages:\n\n```diff\n- import { ids } from '../../src/lexicon/lexicons'\n- import { isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'\n+ import { com } from '../../src/lexicons/index.js'      // generated schemas\n+ import { $Typed, ComAtprotoAdminDefs, ids } from '@atproto/api'  // legacy equivalents\n```\n\nPrefer `../../src/lexicons/index.js` for new code; `@atproto/api` is the\nlower-churn option when the surrounding test is otherwise untouched. Do not\nadd `@atproto/api` back to a package's dependencies just for this — it is a\ndevDependency in `pds` and only a runtime dependency in `bsky` because of\n`getAgeAssuranceRegionConfig`, unrelated to tests.\n\nBefore writing or restructuring tests, use the\n[testing skill](../testing/SKILL.md) — `pds` and `ozone` are jest, `bsky` is\nvitest.\n\n## Related skills\n\n[xrpc-server](../xrpc-server/SKILL.md) documents the target route API in full\nand is the better reference once a package is migrated.\n[lex-setup](../lex-setup/SKILL.md) owns codegen wiring,\n[lex-schema](../lex-schema/SKILL.md) the `$`-accessors, and\n[lex-data](../lex-data/SKILL.md) branded strings, CIDs, and blobs.\n\n<!-- File: .agents/skills/playwright/SKILL.md (Tokens: ~2573 | Category: anthropic-skill) -->\n---\nname: playwright\ndescription: >\n  Drive and inspect this repo's OAuth provider UI and Account Manager (source in\n  packages/oauth/oauth-provider-ui, served by the PDS at /account). Use when\n  writing or extending the puppeteer-based UI tests in packages/pds/tests\n  (oauth.test.ts, account-manager.test.ts), when you need the exact visible\n  label, heading, or error string a screen renders before asserting on it, when\n  demoing, navigating or debugging an OAuth sign-in / sign-up / consent flow\n  locally or against production, when exercising account management (password,\n  email, handle, device sessions, deactivation, deletion), or when a question\n  about behavior can only be answered from the rendered page. Use it even when\n  the request sounds like plain test-writing (\"add a test for the reset-password\n  error case\") — getting the assertion strings right is the whole problem.\ndisable-model-invocation: false\n---\n\n# OAuth & Account Manager UI\n\nThe UI lives in [`packages/oauth/oauth-provider-ui`](../../../packages/oauth/oauth-provider-ui) (React 19, TanStack Router, Radix, Lingui i18n) and is served by the PDS through the OAuth provider's middleware. That package has its own [CLAUDE.md](../../../packages/oauth/oauth-provider-ui/CLAUDE.md) covering component structure and i18n — read it before editing the UI itself.\n\nThere is **no Playwright test harness in this repo**: no `playwright` / `@playwright/test` dependency, no `playwright.config.*`, no `.spec.ts` files. The browser tests are jest + **puppeteer** (`puppeteer` is a devDependency of `packages/pds` only). \"Playwright\" in this skill's name refers to driving a browser interactively for discovery and debugging, not to the test runner.\n\n## Get the strings without a browser first\n\nThe tests assert **French** strings, because they set `languages = ['fr-BE', 'fr', 'en-US', 'en']` to exercise locale negotiation. Booting a stack and spoofing a locale just to read a label is slow and easy to get wrong. Two faster sources:\n\n1. **The message catalog** — [`packages/oauth/oauth-provider-ui/src/locales/fr/messages.po`](../../../packages/oauth/oauth-provider-ui/src/locales/fr/messages.po) maps every source string to its French translation, with the defining component in a `#:` comment above it. Grep it for the English string you saw in the JSX and you have the exact assertion text.\n\n   ```\n   #: src/pages/account/(authenticated)/route.tsx\n   msgid \"My Atmosphere Account\"\n   msgstr \"Mon compte Atmosphère\"\n   ```\n\n   Watch for `msgctxt` — the same English word can have several entries (`\"Sign in\"` under `msgctxt \"verb\"` is `\"Se connecter\"`; the noun form differs).\n\n2. **The mock UI dev server** — renders the real components against a fake API, so no PDS, postgres, redis, or docker is involved:\n\n   ```sh\n   cd packages/oauth/oauth-provider-ui\n   pnpm dev:ui   # http://localhost:5174\n   ```\n\n   Pages: `/account-page.html`, `/authorization-page.html`, `/error-page.html`, `/cookie-error-page.html`. Fixtures (accounts, device sessions, branding) are in [`src/mock-api.ts`](../../../packages/oauth/oauth-provider-ui/src/mock-api.ts) — edit them to reach a state the real backend makes awkward (deactivated account, unverified email, many device sessions). It renders in your browser's own locale, so use it for layout and flow, and the `.po` file for the exact French text.\n\nBoot the full stack only when you need real backend behavior: token issuance, email tokens, PLC propagation, handle resolution, deactivation side effects.\n\n## Booting the real stack\n\n`packages/dev-env` wraps startup in [`with-test-redis-and-db.sh`](../../../packages/dev-infra/with-test-redis-and-db.sh), which needs **docker** and **jq** (it falls back to a native postgres/redis if docker is unavailable). Build first — it runs the compiled `dist/bin.js`, not the sources:\n\n```sh\npnpm install && pnpm build       # once, from the repo root\ncd packages/dev-env && pnpm dev  # PDS on :2583, PLC on :2582, appview on :2584\n```\n\nRun `pnpm dev:ts` from the root in another terminal if you're editing TypeScript alongside.\n\n- Account Manager: `http://localhost:2583/account`\n- Seeded accounts ([`packages/dev-env/src/mock/index.ts`](../../../packages/dev-env/src/mock/index.ts)): `alice.test`, `bob.test`, `carla.test`, all `hunter2`.\n- Setting `NODE_ENV=development` (which `pnpm dev` does) makes the OAuth provider log server-side errors and include stack traces in error responses — worth having when a flow fails opaquely.\n\n### Demo OAuth client\n\n```sh\ncd packages/oauth/oauth-client-browser-example\npnpm dev   # http://127.0.0.1:8080\n```\n\nBackend selection is the `env` query parameter, resolved in [`src/constants.ts`](../../../packages/oauth/oauth-client-browser-example/src/constants.ts). It defaults to `NODE_ENV`, which `pnpm dev` sets to `development` — so plain `http://127.0.0.1:8080/` already points at the local stack and `?env=development` is redundant.\n\nOnly `development` targets the local stack — every other value (including `test` and any typo) falls back to `bsky.social` / `api.bsky.app` for handle resolution and the appview. `development` and `test` additionally set `allowHttp` on the OAuth client, which is what lets either talk to a plain-HTTP loopback PDS.\n\n`?env=test` is therefore **not** a local-stack shortcut. It exists for `oauth.test.ts`, which overrides every endpoint explicitly via query params (`pds_operator_url`, `plc_directory_url`, `handle_resolver`, …) and serves its own bundle through `oauthClientAssetsMiddleware` on a random port, not 8080. Used by hand without those overrides it points at production with HTTP allowed — use `development` instead.\n\nAgainst `production`, sign in only with accounts you own — this is a real authorization grant against real infrastructure, not a sandbox.\n\n## Browser automation: confirm what you have\n\nThis repo registers two browser MCP servers in [`.mcp.json`](../../../.mcp.json) — `playwright` (`@playwright/mcp`) and `chrome-devtools` (`chrome-devtools-mcp`), both launched via `pnpx`. Registration is not connection: the server still has to be enabled and started in your harness, and permissions alone don't make tools exist. Run `claude mcp list` and work with what is actually connected rather than assuming `browser_*` tools are available.\n\nIf the Playwright server is registered but has no browser to drive:\n\n```sh\npnpx @playwright/mcp install-browser chrome-for-testing\n```\n\n**Known trap:** the repo root `package.json` declares `devEngines.packageManager: pnpm` with `onFail: \"error\"`, so **any `npx` invocation fails from the repo root** — including the ones MCP plugins use to launch their servers (`npx @playwright/mcp@latest`, `npx chrome-devtools-mcp@…`). Claude Code spawns MCP servers with cwd set to the project root, so a plugin-provided server can fail its health check here while working fine elsewhere. If `claude mcp list` reports a browser server as failed to connect, this is the likely cause; `pnpx` is unaffected, so a server entry using `pnpx` instead of `npx` starts cleanly.\n\nIf no browser MCP is reachable and you can't add one, fall back to the mock UI plus the `.po` catalog for discovery, and to running the puppeteer tests with `headless: false` (the option is already there, commented out, in both test files) for interactive debugging.\n\nTool-name note if you do have the Playwright MCP: `browser_navigate({ url })`, `browser_snapshot({ boxes })`, `browser_click({ element, target })`, `browser_type({ element, target, text })`, `browser_fill_form({ fields })`, `browser_wait_for({ text })`, `browser_console_messages({ level })`, `browser_network_requests({ static })`. `target` takes a ref from a prior snapshot or a CSS selector. Prefer `browser_snapshot` over screenshots — it returns the refs you need to interact, and screenshots don't.\n\n## Writing or extending a UI test\n\nThe two browser test files are [`packages/pds/tests/oauth.test.ts`](../../../packages/pds/tests/oauth.test.ts) and [`packages/pds/tests/account-manager.test.ts`](../../../packages/pds/tests/account-manager.test.ts). Read the neighbouring cases first — they are the best available spec for how these flows behave.\n\n**Discover before you assert.** Reading a component tells you what `<Trans>` wraps; it doesn't tell you which branch renders, what the negotiated locale produces, or what a Radix dialog actually puts in the DOM. Get the real strings from the mock UI, a driven browser, or the `.po` catalog, then write the test.\n\nBoth files share the `PageHelper` wrapper in [`packages/pds/tests/_puppeteer.ts`](../../../packages/pds/tests/_puppeteer.ts) — use it rather than raw puppeteer:\n\n- `goto(url)` / `reload()` — navigate.\n- `assertTitle(text)` — waits for network idle, then asserts `document.title`.\n- `clickOnText(text, tag = 'button')` / `clickOnAriaLabel(label, tag = 'button')` / `clickOn(selector)` — each waits for the element to be _visible_ first.\n- `typeInInput(name, text)` (targets `input[name=\"…\"]`) / `typeIn(selector, text)` — both clear the field first and return the `ElementHandle`, so you can `press('Enter')` on it.\n- `ensureTextVisibility(text, tag = 'p', timeout = 5000)` / `ensureNotification(text)` (`tag = 'div'`).\n- `waitForNetworkIdle()`, `navigationClick(text, tag)` / `navigationAction(fn)` for anything triggering a full navigation, `waitForPopup(fn)` → a `PageHelper` for the popup.\n\n`PageHelper` is `AsyncDisposable`; open pages with `await using page = await PageHelper.from(browser, { languages })` so they close even when a test throws.\n\nTwo recurring gotchas visible in the existing tests:\n\n- `clickOnText` matches on substring via puppeteer's `::-p-text()`, so a label that is a prefix of another (\"Réactiver\" inside \"Réactiver le compte\") hits whichever comes first in DOM order. Target the element directly instead — e.g. `clickOn('[role=\"dialog\"] button[type=\"submit\"]')`.\n- Mailed tokens are obtained by spying on the mailer (`jest.spyOn(network.pds.ctx.mailer, 'sendResetPassword')`) and reading `mock.lastCall`; those calls also assert the negotiated `locale`, which is how the i18n path stays covered.\n\n### Running\n\nThese suites are stateful — each `it` builds on the account state the previous one left behind — so run the whole file, never a single case:\n\n```sh\ncd packages/pds\npnpm test -- tests/oauth.test.ts\n```\n\nGo through `pnpm test`: it wraps jest in the postgres/redis script, and `pretest` installs the Chrome build puppeteer needs.\n\n## Related skills\n\n- [testing](../testing/SKILL.md) — runner choice (vitest vs jest), test file location, tsconfig setup. It routes browser-driven work here; come back to it for anything that isn't a UI test. New tests elsewhere in the repo should be vitest — these two files are jest because `pds` hasn't been migrated.\n\n<!-- File: .agents/skills/testing/references/jest.md (Tokens: ~914 | Category: anthropic-skill) -->\n# Jest patterns (deprecated)\n\nJest (currently v30) is deprecated here; vitest is the standard. This reference covers adding cases to existing jest files and maintaining them until migration. Don't introduce jest into a package that doesn't already have it — for a fresh package, see [vitest.md](vitest.md).\n\nA package is on jest if it has a `jest.config.cjs` and a `\"test\"` script invoking `jest` with `NODE_OPTIONS=--experimental-vm-modules` (required because the repo is ESM-only). The root [jest.config.cjs](../../../../jest.config.cjs) aggregates them via `projects: ['<rootDir>/packages/*/jest.config.cjs']` — note the single-level glob, so a jest package nested under `packages/lex/` or `packages/oauth/` would not be picked up. Check the package's own config rather than trusting any list.\n\n## TypeScript config\n\nJest packages extend [tsconfig/jest.tsconfig.json](../../../../tsconfig/jest.tsconfig.json), which adds `\"types\": [\"node\", \"jest\"]` on top of the shared node config:\n\n```jsonc\n{\n  \"extends\": [\"../../tsconfig/jest.tsconfig.json\"],\n  \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n  \"compilerOptions\": {\n    \"rootDir\": \".\",\n  },\n  \"references\": [\n    { \"path\": \"./tsconfig.build.json\" },\n    // Integration tests only — see below\n    { \"path\": \"../dev-env/tsconfig.build.json\" },\n  ],\n}\n```\n\n`./tsconfig.build.json` covers the package's own build-time deps and is all most jest packages have. Of the 13, only `api`, `ozone`, `pds`, and `sync` add the `dev-env` entry, because their tests import `@atproto/dev-env` while `src` doesn't. `packages/did` has jest tests but no `tsconfig.test.json` at all — a gap, not a pattern.\n\nThe `include` glob covers colocated `src/**/*.test.ts` even though jest packages barely use them (`packages/api/src/age-assurance.test.ts` is the only one) — leave it as-is so the two runners' configs stay uniform. A package adding custom matchers also needs them declared: [packages/api](../../../../packages/api/tsconfig.test.json) sets `\"types\": [\"jest\", \"./jest.d.ts\"]` to pick up its ambient `jest.d.ts`.\n\n## Writing tests\n\nMost structural conventions from [vitest.md](vitest.md) carry over — `describe`/`it`, `it.each`, fixtures at the top of the file, `using` for spies (jest 30's spies are `Disposable` too). What differs:\n\n| vitest                   | jest                                                                                                                              |\n| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |\n| `vi.fn<FnType>()`        | `jest.fn<FnType>()` (single type param since jest 30)                                                                             |\n| `vi.spyOn` / `vi.mock`   | `jest.spyOn` / `jest.mock`                                                                                                        |\n| `assert()` from `vitest` | `import assert from 'node:assert'` — the prevailing pattern in `pds` tests                                                        |\n| `rejects.toSatisfy(cb)`  | not a jest matcher; use `rejects.toMatchObject({ error: '…' })`, or `const err = await fn().catch((e) => e)` then assert on `err` |\n| `expectTypeOf`           | no equivalent; skip type-level assertions or migrate the file                                                                     |\n\nJest globals are ambient via `@types/jest`, but a minority of files import them explicitly from `@jest/globals`. Match whatever the surrounding files in the package do rather than mixing styles within one package.\n\n## Setup files\n\nTranspilation is `@swc/jest`, not `ts-jest`, with a `moduleNameMapper` that resolves the repo's explicit `.js` import extensions back to `.ts` sources.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests). Jest does accept a path after `--`, but omitting `--` works in both runners — so just use `pnpm test path/to/file.test.ts` and the habit stays correct when the file migrates.\n\n<!-- File: .agents/skills/testing/references/vitest.md (Tokens: ~2842 | Category: anthropic-skill) -->\n# Vitest patterns\n\nRepo-specific conventions and setup. Assumes working knowledge of vitest itself (currently v4) — only the things this codebase does differently from the defaults are documented here.\n\n## Adopting vitest in a new package\n\n1. Add `vitest` to `devDependencies` and set `\"test\": \"vitest run\"` in `package.json`.\n2. Create `vitest.config.ts` in the package root. Nearly every package in the repo has exactly this and nothing more:\n\n   ```ts\n   import { defineProject } from 'vitest/config'\n\n   export default defineProject({\n     test: {},\n   })\n   ```\n\n   `defineProject` (not `defineConfig`) is what makes the package composable into the root workspace runner. Add `setupFiles`, `testTimeout`, `resolve.alias`, etc. only when the package actually needs them — [packages/bsky](../../../../packages/bsky/vitest.config.ts) and [packages/ws-client](../../../../packages/ws-client/vitest.config.ts) are the only two that do.\n\n   **Exception:** if the package already has a `vite.config.*`, don't create `vitest.config.ts` — see [Vite-based packages](#vite-based-packages).\n\n3. Register the package in the root [vitest.config.ts](../../../../vitest.config.ts) under `test.projects` so `pnpm test:unit` and the VSCode Vitest extension pick it up. Several entries are globs (`packages/lex/*`, `packages/oauth/*`, `packages/internal/*`), so a package placed under one of those directories needs no edit.\n\n   **Skip this step if the tests need dev-infra** (the postgres + redis docker stack). The workspace runner doesn't start dev-infra, so those tests would fail when invoked from the root. That's why `packages/bsky` is commented out of the list — it must run from its own directory via `pnpm test`, which goes through [with-test-redis-and-db.sh](../../../../packages/dev-infra/with-test-redis-and-db.sh). `packages/lexicon-resolver` is also missing from the list, but without a comment and without a dev-infra wrapper on its `test` script, so treat its absence as an oversight rather than a pattern to copy.\n\n4. Create `tsconfig.test.json` extending the shared vitest config (never the jest one, which pulls in `@types/jest`):\n\n   ```json\n   {\n     \"extends\": [\"../../tsconfig/vitest.tsconfig.json\"],\n     \"include\": [\"./tests\", \"./src/**/*.test.ts\"],\n     \"compilerOptions\": {\n       \"rootDir\": \".\"\n     },\n     \"references\": [{ \"path\": \"./tsconfig.build.json\" }]\n   }\n   ```\n\n   Adjust the `../../` depth for nested packages (`packages/lex/*` and `packages/oauth/*` need `../../../`). `./tsconfig.build.json` is normally the only reference needed — it already pulls in the package's build-time deps, so a test importing `@atproto/lex-data` from `lex-json` resolves without an extra entry. Add one only for a package the tests import but `src` doesn't; that's `{ \"path\": \"../dev-env/tsconfig.build.json\" }` in the integration-test packages, and little else.\n\n   Extend `include` for other test-only sources: benchmarks (`./src/**/*.bench.ts`, as [lex-json](../../../../packages/lex/lex-json/tsconfig.test.json) does) or ambient declarations (`./src/core-js.d.ts`, as [lex-document](../../../../packages/lex/lex-document/tsconfig.test.json) does).\n\n5. Make sure the package's `tsconfig.json` references `./tsconfig.build.json`, `./tsconfig.test.json`, and — so the new `vitest.config.ts` gets type-checked — `./tsconfig.config.json`. If the package has no `tsconfig.config.json` yet, create one extending [tsconfig/config.tsconfig.json](../../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]` (adjust the `../../` depth for nested packages). See the SKILL's [TypeScript config](../SKILL.md#typescript-config-for-tests) section.\n\n## Vite-based packages\n\nA package with a `vite.config.*` must use vitest, configured **inside that existing Vite config**. Vitest reads `vite.config.*` natively, so a separate `vitest.config.ts` would shadow the package's real build setup (plugins, `resolve.alias`, `optimizeDeps`, `conditions`) and the tests would run against a different module graph than the app. Jest isn't an option at all here — it can't consume the Vite plugin pipeline.\n\nTwo additions to the Vite config, as in [oauth-provider-ui](../../../../packages/oauth/oauth-provider-ui/vite.config.js):\n\n1. `/// <reference types=\"vitest/config\" />` on the **first line** — this types the `test` key, which `defineConfig` from `vite` doesn't know about on its own.\n2. A `test: {}` key in the exported config, kept empty unless an option is genuinely needed.\n\nEverything else is unchanged: `vitest` in `devDependencies`, `\"test\": \"vitest run\"`, root `projects` registration, and a `tsconfig.test.json`.\n\nBecause the tests share the app's Vite config, they inherit its `resolve.alias` — import through the package's own aliases (`#/lib/foo.js`) exactly as production code does, rather than reaching for brittle relative paths.\n\n## Imports\n\nImport test utilities by name from `vitest`; nothing is ambient:\n\n```ts\nimport { assert, describe, expect, it, vi } from 'vitest'\n```\n\nPrefer vitest's `assert` over `node:assert` — it narrows types and reports through vitest's error formatting.\n\n## Labels\n\n`it` and `test` are aliases at runtime, but this repo picks between them by what the label _is_:\n\n- **`it('<description>', …)`** when the label is a sentence describing behavior. Reads as \"it rejects invalid request bodies\".\n- **`test(case, …)`** when the label identifies a specific case: a function reference, a fixture name, a parameterized row. Reads as \"test `parseCid`\".\n\nFor `describe`, pass the function or class **reference** rather than a string when the suite covers a single named export — the label stays correct through renames:\n\n```ts\ndescribe(parseCid, () => { … })\ndescribe(XrpcResponseError, () => { … })\n```\n\nUse a string label for conceptual groups that don't map to one export — an arrow function assigned to a const, a class method, a roundtrip pair (`describe('roundtrip toBase64 <-> fromBase64', …)`). Integration suites under `./tests` span many endpoints and are string-labeled by convention: `describe('pds profile views', …)`.\n\n## Parameterized tests\n\nPrefer `test.each` over a hand-rolled `for` loop. Each row is a case identifier, hence `test` and not `it`:\n\n```ts\ndescribe(isReconnectableClose, () => {\n  test.each([\n    { note: 'normal shutdown', code: CloseCode.Normal, expected: false },\n    { note: 'internal error', code: CloseCode.InternalError, expected: true },\n  ])('$note', ({ code, expected }) => {\n    expect(isReconnectableClose(code)).toBe(expected)\n  })\n})\n```\n\nThe label template is `'$note'` — `$`-prefixed, referring to a property of the row object. `'{note}'` does _not_ interpolate; it renders literally, so every case ends up with the same name.\n\nTo run one suite against several implementations of an interface, the implementation is the case identifier, so `describe.each` takes it — but the inner labels still describe behavior, so they stay `it`:\n\n```ts\ndescribe.each([utf8LenNode, utf8LenCompute])('%o', (utf8Len) => {\n  // some implementations are runtime-optional; assert before the tests read them\n  assert(utf8Len, 'utf8Len implementation should not be null')\n\n  it('computes utf8 string length', () => {\n    expect(utf8Len('a')).toBe(1)\n  })\n})\n```\n\nFall back to `for (const c of cases) { test(c.name, …) }` only when the body shape genuinely can't be expressed as `test.each` — different nesting or a different number of assertions per case.\n\n### Fixture files\n\nImport JSON fixtures with the import attribute and hand the array straight to `test.each`, keyed on a label property:\n\n```ts\nimport fixtures from './data-model-valid.json' with { type: 'json' }\n\ntest.each(fixtures)('$note', (fixture) => { … })\n```\n\nFor the shared text fixtures in `interop-test-files/`, use the existing [`readInteropFile`](../../../../packages/syntax/tests/_utils.ts) helper rather than re-implementing the read-and-filter logic:\n\n```ts\ntest.each(readInteropFile('aturi_syntax_valid.txt'))('%s', (value) => { … })\n```\n\n## Assertions\n\n### `assert` for narrowing\n\n`assert()` narrows types; `expect(…).toBe(true)` does not. Preferring it means the rest of the test can read narrowed properties without a second guard or a cast:\n\n```ts\nassert(result.success)\nexpect(result.body).toEqual({ value: 'hello' }) // result.body is typed here\n```\n\nThe same applies to instance checks before property assertions:\n\n```ts\nassert(err instanceof XrpcFetchError)\nexpect(err.cause).toBeInstanceOf(TypeError)\n```\n\n`expect(result).toMatchObject({ success: true })` is the worst of the three — no narrowing, and a weaker diagnostic on failure.\n\n### Rejections\n\n- `rejects.toThrow()` — the only assertion is that something threw.\n- `rejects.toThrow(message)` — the only assertion beyond that is the message, as a string or regex.\n- `rejects.toSatisfy(…)` — everything else, including checking exactly one non-message property (`err.cause`, `err instanceof X`, `err.code`).\n\nInside a `toSatisfy` callback, a failing `expect()` or `assert()` propagates as the test failure, so the assertions read normally and no try/catch is needed. The callback has to `return true` at the end — that line is only reached when everything passed:\n\n```ts\nawait expect(someAsyncFn()).rejects.toSatisfy((err) => {\n  assert(err instanceof SomeError)\n  expect(err.cause).toBeInstanceOf(TypeError)\n  return true\n})\n```\n\n### Type-level assertions\n\nUse `expectTypeOf` rather than a hand-rolled `const expectType = <T>(_: T) => {}` helper — it compiles to nothing, so it's free at runtime. This repo uses `toEqualTypeOf` for exact matches and `toMatchObjectType` for partial ones. Avoid `toMatchTypeOf`, which upstream deprecated in favor of those two.\n\n```ts\nexpectTypeOf(result.body).toEqualTypeOf<{ blobRef: l.BlobRef }>()\nexpectTypeOf(response.body).toMatchObjectType<{ cursor?: string }>()\n```\n\nFor deliberately invalid arguments, `// @ts-expect-error` with a reason:\n\n```ts\n// @ts-expect-error intentionally passing invalid params\nparams: { limit: 'not-a-number' },\n```\n\n## Mocks and spies\n\nType `vi.fn` with the function type when the test inspects how it was called:\n\n```ts\nconst fetchHandler = vi.fn<FetchHandler>(async () =>\n  Response.json({ value: 'ok' }),\n)\n\nawait xrpc(fetchHandler, testQuery, { params: { limit: 25 } })\n\nexpect(fetchHandler).toHaveBeenCalledOnce()\nconst [path] = fetchHandler.mock.calls[0]\nexpect(path).toContain('/xrpc/io.example.testQuery')\n```\n\nWhen calls don't need inspecting, a plain typed function is clearer than a mock: `const fetchHandler: FetchHandler = async () => …`.\n\n### Module mocks\n\nMocking a node built-in usually needs both the default and the named exports, since callers may use either form ([lexicon-resolver](../../../../packages/lexicon-resolver/tests/lexicon.test.ts) does this for DNS):\n\n```ts\nvi.mock('node:dns/promises', () => {\n  const mock = { resolveTxt: vi.fn() }\n  return { default: mock, ...mock }\n})\n```\n\nTo override only part of a module, spread the actual one:\n\n```ts\nvi.mock('./const.js', async () => {\n  const actual = await vi.importActual<Record<string, unknown>>('./const.js')\n  return { ...actual, KWS_V2_COUNTRIES: new Set(['AA']) }\n})\n```\n\nNote that `bsky` integration tests mock the **compiled** path (`../../dist/api/…`), not `../../src/…`, because the service under test is loaded from `dist`. Match whatever the module graph actually resolves, or the mock silently won't apply.\n\n## Running tests\n\nSee [Running tests in SKILL.md](../SKILL.md#running-tests) — in particular, don't put `--` before a file path.\n\n<!-- File: .agents/skills/testing/SKILL.md (Tokens: ~2286 | Category: anthropic-skill) -->\n---\nname: testing\ndescription: >\n  Apply this monorepo's testing practices — runner choice (vitest vs jest),\n  where test files belong, tsconfig wiring, and the dev-env fixture used by\n  integration tests. Use before any code search when asked to add, write, or\n  extend tests, add coverage, reproduce a bug with a failing test, choose or\n  configure a test runner, decide where a test belongs, run a single test file,\n  or test a browser-based user flow. Covers unit, integration, and end-to-end\n  tests, and routes UI work to the playwright skill.\ndisable-model-invocation: false\n---\n\n# Testing in this repo\n\n## UI tests: get the assertion strings right first\n\nIf the test drives a UI (`packages/pds/tests/{oauth,account-manager}.test.ts`, or anything using the `puppeteer` `PageHelper`), the assertion strings are the whole problem — they are **French** and branch-dependent, so they can't be guessed from the JSX. Read the playwright skill before asserting on any of them: the fastest source is the `fr` message catalog, with a browser walkthrough as the fallback when the rendered branch is unclear.\n\nFull workflow, `PageHelper` API, and dev-env boot instructions: [playwright skill](../playwright/SKILL.md). Come back here for runner and tsconfig questions.\n\n## Choosing a runner\n\nVitest is the standard and now covers the majority of the repo (~25 packages, ~270 test files) — including `bsky`, all of `packages/lex/*`, `packages/oauth/*`, and the tested `packages/internal/*`. Jest remains in 13 unmigrated packages (~165 files), among them `pds`, `ozone`, `api`, `repo`, and `xrpc-server`. Jest is deprecated: don't introduce it anywhere new, and prefer migrating a file to vitest over deepening its jest usage.\n\nDecide from the package's own config files, not from a memorized list — they are the only drift-proof signal:\n\n1. Package has a `vite.config.*` → **Vitest, configured inside that Vite config** (never a separate `vitest.config.ts`). See [Vite-based packages](references/vitest.md#vite-based-packages).\n2. Package has a `vitest.config.ts` → Vitest. See [references/vitest.md](references/vitest.md).\n3. Package has a `jest.config.cjs` → Jest. See [references/jest.md](references/jest.md).\n4. None of the above → the package has no test setup. Adopt vitest: create `vitest.config.ts` and `tsconfig.test.json` first, following [the setup section](references/vitest.md#adopting-vitest-in-a-new-package), then write the test.\n\nIf the test imports `@atproto/lex` or any `@atproto/lex-*` package, also read the [lex-schema skill](../lex-schema/SKILL.md) — schemas expose `$parse` / `$safeParse` / `$matches`, which make better assertions than hand-rolled shape checks.\n\n## Where tests go\n\n- **Unit tests** sit next to their subject: `foo.ts` + `foo.test.ts`. This is the default whenever the test needs no infra and no cross-package setup.\n- **Integration / end-to-end tests** go in the package's top-level `./tests` folder (`packages/{pds,bsky,ozone}/tests`, `packages/lex/lex/tests`, …). Use it for tests that boot real services, hit a database, or exercise several modules together.\n- **Shared helpers** go in `./tests/_util.ts` (or a `./tests/_util/` directory). The leading underscore keeps them out of glob-based test discovery — see [packages/bsky/tests/\\_util.ts](../../../packages/bsky/tests/_util.ts) and [packages/ws-client/tests/\\_util/](../../../packages/ws-client/tests/_util).\n\nJest packages predate the colocation convention and keep everything in `./tests` — `packages/api/src/age-assurance.test.ts` is the lone exception. Don't relocate existing files just to match the convention.\n\n## Integration tests use the dev-env fixture\n\nAlmost every integration test in `pds`, `bsky`, `ozone`, and `lexicon-resolver` boots a real service constellation through `@atproto/dev-env` rather than mocking. Reach for it instead of hand-wiring servers:\n\n- `TestNetwork.create({ dbPostgresSchema })` — PDS + AppView + bsync + ozone + PLC. Needs postgres and redis. The schema must be unique per test file: it's the isolation boundary (the AppView and ozone databases are derived from it), so parallel files sharing one corrupt each other's data.\n- `TestNetworkNoAppView.create({ pds, plc })` — PDS + PLC only, on a temp-dir store and a mock PLC database. Lighter; use it when the test doesn't touch the AppView. It forwards only the `pds` and `plc` sub-options, so passing a top-level `dbPostgresSchema` here does nothing.\n- `TestPds.create(config, overrides)` — a lone PDS; pair with `TestPlc.create({})` and set `didPlcUrl: plc.url`. The optional second argument is passed to `PDS.create` as `AppContext` overrides (e.g. `{ plcRotationKey }`); neither network class forwards it.\n- `network.getSeedClient()` plus a seed exported from `@atproto/dev-env` (`basicSeed`, `usersSeed`, `quotesSeed`, …) populates accounts and records.\n- `await network.processAll()` flushes the firehose so the AppView has caught up before you assert.\n\nTear down with `afterAll(async () => network?.close())` — the prevailing pattern, and the only option for `TestNetworkNoAppView`, which exposes `close()` but no `Symbol.asyncDispose`. Only `TestNetwork` implements it.\n\nA test that resolves identities (handles, `did:plc`, `did:web`) against localhost must construct its resolver with `fetch: globalThis.fetch` — `@atproto/identity`'s default fetch is SSRF-protected and refuses localhost/private-IP targets silently.\n\nSnapshot assertions go through each package's `forSnapshot()` helper in `tests/_util.ts`, which swaps DIDs, CIDs, and timestamps for stable placeholders so snapshots don't churn on every run. Refresh them with `pnpm test:updateSnapshot` (defined in `bsky`, `pds`, `ozone`, `bsync`).\n\n## TypeScript config for tests\n\nTested packages split their TS config, each part referenced from the package's `tsconfig.json`:\n\n```json\n{\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.config.json\" },\n    { \"path\": \"./tsconfig.build.json\" },\n    { \"path\": \"./tsconfig.test.json\" }\n  ]\n}\n```\n\n- `tsconfig.build.json` — `./src`, excludes `**/*.test.ts`, emits to `./dist`.\n- `tsconfig.test.json` — test code, extending `tsconfig/vitest.tsconfig.json` or `tsconfig/jest.tsconfig.json`. Both set `noEmit` and `composite: false`; the jest one additionally pulls in `@types/jest`. `include` is `[\"./tests\", \"./src/**/*.test.ts\"]` in nearly every package, regardless of runner.\n- `tsconfig.config.json` — root-level config/script files (`vitest.config.ts`, `jest.config.cjs`, …) that neither of the above covers, extending [tsconfig/config.tsconfig.json](../../../tsconfig/config.tsconfig.json) with `include: [\"./*.ts\", \"./*.js\", \"./*.cjs\", \"./*.mjs\"]`. `exclude` any root file that imports `./src` or `./tests` (e.g. a `jest.setup.ts`), or it drags the whole tree into this stricter program. Present whenever the package has such a file — which is nearly all of them.\n\nThe build is a TS project graph, so `references` is what makes imports resolve. `./tsconfig.build.json` alone covers anything the package already depends on at build time — which is most workspace imports. Add a further entry only for a package the tests import but `src` doesn't: in practice that means `{ \"path\": \"../dev-env/tsconfig.build.json\" }`, and only in the six packages with integration tests (`api`, `bsky`, `lexicon-resolver`, `ozone`, `pds`, `sync`).\n\nCreate `tsconfig.test.json` before writing the first test in a package that lacks one — `packages/did` has jest tests without one, so don't take its layout as the pattern. Exact contents per runner: [vitest](references/vitest.md#adopting-vitest-in-a-new-package) / [jest](references/jest.md#typescript-config).\n\n## Running tests\n\nRun from inside the package directory:\n\n```bash\npnpm test                        # full package suite\npnpm test path/to/file.test.ts   # single file\n```\n\nPackages whose tests need docker infra wrap the runner in a dev-infra script — `bsky`, `pds`, `ozone`, and `sync` use [with-test-redis-and-db.sh](../../../packages/dev-infra/with-test-redis-and-db.sh); `bsync` uses `with-test-db.sh`. Always go through `pnpm test`; invoking `vitest` or `jest` directly skips the script, so postgres and redis aren't running and the suite dies on connection errors. `pds` also offers `pnpm test:sqlite` for a faster loop that skips the docker infra.\n\nFrom the repo root, `pnpm test` runs every package's suite with infra up, and `pnpm test:unit` runs only the projects registered in [vitest.config.ts](../../../vitest.config.ts). That list is not the full set of vitest packages: `bsky` is commented out because it needs infra, and `lexicon-resolver` is simply absent. Run either from its own directory.\n\n## Logs\n\nEvery part of the code base uses pino logging, which is disabled by default. Enable it with `LOG_ENABLED=true` and redirect to a file with `LOG_DESTINATION=...`. For example, to run the full test suite and capture logs:\n\n```bash\nLOG_ENABLED=true LOG_DESTINATION=test.log pnpm test ...\n```\n\nPretty print using:\n\n```bash\ncat test.log | pnpm exec pino-pretty\n```\n\n## Code style\n\n- Always use `using` for spies, mocks and any object that implements `Disposable` or `AsyncDisposable`.\n\n<!-- File: .agents/skills/update-chat-lexicons/SKILL.md (Tokens: ~797 | Category: anthropic-skill) -->\n---\nname: update-chat-lexicons\ndescription: >\n  Sync `chat.bsky.*` Lexicon JSON from the private `bluesky-social/chat` repo\n  into this monorepo, regenerate the code derived from it, and add a changeset.\n  Use whenever asked to update, sync, pull, refresh, or copy chat lexicons, to\n  pick up new or changed chat convo, group, moderation, or notification\n  definitions, or to reconcile `lexicons/chat/` with the chat service —\n  including when the request is just \"update chat lexicons\".\ndisable-model-invocation: false\n---\n\n# Update chat lexicons\n\n`lexicons/chat/bsky/**` is a mirror. The source of truth is the separate\n`bluesky-social/chat` repo, so an update is a copy _plus_ codegen _plus_ a\nchangeset — the copy on its own leaves the generated clients stale.\n\n## 1. Refresh the source checkout\n\nThis assumes a sibling clone at `../chat`. Update it first: step 2 copies with\n`--delete`, so syncing from a checkout that is behind will delete lexicons that\nstill exist upstream.\n\n```bash\ngit -C ../chat pull --ff-only\n```\n\nIf `../chat` doesn't exist, `bluesky-social/chat` is private and not\nweb-fetchable — clone it with `gh repo clone bluesky-social/chat ../chat`, or\nask the user where their checkout lives and substitute that path below. Don't\nimprovise a source from the destination; there would be nothing to sync.\n\n## 2. Copy\n\n```bash\nrsync -a --delete ../chat/lexicons/chat/ ./lexicons/chat/\n```\n\nOnly `lexicons/chat/` is mirrored. The chat repo also carries `app/`, `com/`,\nand `tools/` lexicons that diverge from this repo's — never sync those.\n\nCheck `git status` immediately. Unexpected deletions almost always mean the\nsource checkout is behind, not that definitions were dropped upstream.\n\n## 3. Regenerate\n\n```bash\npnpm codegen\n```\n\nRun this from the repo root, and use `pnpm codegen` rather than `pnpm build`:\n`packages/api` and `packages/ozone` regenerate only when a lexicon file is\n_newer_ than their generated index, and `rsync -a` preserves the source's\nmtimes — so a build can decide nothing changed and silently keep stale types.\n`pnpm codegen` invokes the generators unconditionally, building the codegen\ntooling itself first.\n\nBoth generated trees are gitignored, so the commit diff shows only JSON. The\nreal blast radius stays invisible until you build.\n\n## 4. Changeset\n\nAdd a file under `.changeset/` with a three-word kebab-case name:\n\n```markdown\n---\n'@atproto/api': patch\n---\n\nupdate chat lexicons\n```\n\nA patch on `@atproto/api` alone is the established convention here (see\n`git log --oneline -- lexicons/chat`). `@atproto/ozone` also generates from\nthese lexicons but picks up an automatic dependency bump, so don't list it by\nhand. Use `minor` instead if the sync removes or renames a definition — that\nbreaks the published types.\n\n## 5. Verify\n\n```bash\n(cd packages/api && pnpm build && pnpm test)\n(cd packages/ozone && pnpm build)\n```\n\nOzone's build typechecks its route handlers against the regenerated server\ntypes, which is where a removed or narrowed chat definition actually surfaces.\n\nThen review the diff and commit. Before opening a PR, run the full checks from\nthe root: `pnpm build --force && pnpm verify`.\n\n<!-- File: .agents/skills/xrpc-server/SKILL.md (Tokens: ~3055 | Category: anthropic-skill) -->\n---\nname: xrpc-server\ndescription: >\n  Build or edit routes on an AT Protocol service that uses\n  `@atproto/xrpc-server` — `packages/pds`, `packages/bsky`, or any package whose\n  handlers call `server.add()`. Use when adding or changing an endpoint, wiring\n  `createServer` into Express, reading params/input/auth in a handler, returning\n  JSON, binary, streamed, or empty responses, setting response headers, throwing\n  XRPC protocol errors, applying per-route rate limits or body limits, or\n  exposing a WebSocket subscription. To migrate a service off the legacy\n  generated server stack, use the lexification-server skill instead. For calls\n  the service makes *out* to other services, use the lex-client skill.\ndisable-model-invocation: false\n---\n\n# `@atproto/xrpc-server`\n\nThe server half of the AT Protocol XRPC stack. Service code creates a `Server`\nand registers endpoints by passing generated lexicon schemas to `server.add()`.\n\nImport it directly — it is **not** re-exported by `@atproto/lex`:\n\n```ts\nimport { InvalidRequestError, type Server } from '@atproto/xrpc-server'\n```\n\nThis is the server package the services actually use — `@atproto/lex-server` is\na separate package that no service in this repo consumes. `packages/pds`,\n`packages/bsky` and `packages/ozone` are all fully on `server.add()`.\n\nWatch the casing: `XRPCError` is this package's server-side class, thrown in\nhandlers. `XrpcError` is the _client_-side class from `@atproto/lex-client`.\n\n## Creating the server\n\n```ts\nimport { createServer } from '@atproto/xrpc-server'\n\nconst server = createServer([], {\n  validateResponse: config.debugMode,\n  payload: {\n    jsonLimit: 100 * 1024,\n    textLimit: 100 * 1024,\n    blobLimit: 5 * 1024 * 1024,\n  },\n})\n\napp.use(server.router) // Express\n```\n\nThe first argument is an array of legacy `LexiconDoc`s — pass `[]` for\nschema-based setups. It is not middleware. Other `Options` worth knowing:\n`catchall` (a fallback `RequestHandler`, used by the PDS for proxying),\n`errorParser` (map non-`XRPCError` throwables onto XRPC errors before they are\nrendered), and `rateLimits` (global/shared limiter wiring).\n\n`server.router` is the Express app. Legacy generated servers expose\n`server.xrpc.router` instead — if you see that, you are in legacy code.\n\nWebSocket upgrades are wired up automatically when the server's router is\nmounted on an app that later calls `.listen()`; there is nothing extra to do\nfor subscriptions beyond registering them.\n\n## Registering a route\n\n`server.add(schema, handlerOrConfig)`. The schema is the generated lexicon\nobject, e.g. `com.atproto.identity.resolveHandle`.\n\nBare-function form when the route needs nothing but a handler:\n\n```ts\nimport type { Server } from '@atproto/xrpc-server'\nimport { com } from '../../../lexicons/index.js'\n\nexport default function (server: Server, ctx: AppContext) {\n  server.add(com.atproto.identity.resolveHandle, async ({ params }) => {\n    const did = await resolve(params.handle)\n    return { encoding: 'application/json', body: { did } }\n  })\n}\n```\n\nObject form for anything else — `auth`, `rateLimit`, `opts`:\n\n```ts\nserver.add(app.bsky.draft.createDraft, {\n  auth: ctx.authVerifier.standard,\n  handler: async ({ input, auth }) => {\n    const actorDid = auth.credentials.iss\n    if (atLimit)\n      throw new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\n    return { encoding: 'application/json', body: { draft } }\n  },\n})\n```\n\nRegistration is conditional-friendly: it is normal to `return` early when a\ndependency is absent (`if (!ctx.bskyAppView) return`), or to register a\ndifferent handler for the same NSID on each branch of an `if`.\n\n### Handler context\n\n`{ params, input, auth, req, res, resetRouteRateLimits }`. Queries read\n`params`, procedures read `input.body`; `req`/`res` are Express objects, useful\nfor reading request headers or setting response headers directly.\n\nThere is **no `signal`** on a method handler — only subscription handlers get\none. `resetRouteRateLimits()` clears the consumed points for this route,\ne.g. after a successful login.\n\n### Route options and rate limits\n\n```ts\nserver.add(com.atproto.repo.createRecord, {\n  auth: ctx.authVerifier.authorization({ authorize: () => {} }),\n  rateLimit: [\n    {\n      name: 'repo-write-hour',\n      calcKey: ({ auth }) => auth.credentials.did,\n      calcPoints: () => 3,\n    },\n  ],\n  opts: { jsonLimit: 1_000_000 },\n  handler: async ({ input, auth }) => {\n    /* ... */\n  },\n})\n```\n\n`rateLimit` is one entry or an array; `{ name }` references a shared limiter\ndeclared in `createServer`'s `rateLimits.shared`, while `{ durationMs, points }`\ndeclares a route-local one. `opts` is `RouteOptions` — `jsonLimit`, `textLimit`,\n`blobLimit`, `paramsParseLoose` — overriding the server-wide `payload` defaults.\n\n## Return values\n\nA JSON response is `{ encoding, body }`, optionally with `headers`:\n\n```ts\nreturn {\n  encoding: 'application/json',\n  body: result,\n  headers: { 'server-timing': serverTimingHeader([timerSkele, timerHydr]) },\n}\n```\n\nOther shapes the server understands:\n\n| Return                           | Effect                                                                   |\n| -------------------------------- | ------------------------------------------------------------------------ |\n| nothing (`undefined`)            | 200 with an empty body — correct for lexicons with no `output`           |\n| `{ encoding, body: Readable }`   | streamed binary response                                                 |\n| `{ encoding, buffer, headers? }` | `HandlerPipeThroughBuffer`                                               |\n| `{ encoding, stream, headers? }` | `HandlerPipeThroughStream` — what the PDS `pipethrough()` helper returns |\n\nBinary example, from `packages/pds/src/api/com/atproto/sync/getBlob.ts`:\n\n```ts\nres.setHeader('content-security-policy', `default-src 'none'; sandbox`)\nreturn {\n  encoding: found.mimeType || ('application/octet-stream' as const),\n  body: found.stream,\n}\n```\n\nBodies are validated against the schema's output unless the server was\nconstructed with `validateResponse: false`.\n\n### When `encoding` needs `as const`\n\nReturning the object literal _directly_ from a handler that declares at least\none parameter is contextually typed by `server.add()`, so `encoding:\n'application/json'` is already narrow and needs nothing — this is what most\nhandlers in `packages/bsky/src/api/` do.\n\nContextual typing is lost in two cases, and then `'application/json'` widens to\n`string` and no longer matches the schema:\n\n- the response is built into an intermediate variable before being returned\n- the handler declares no parameters at all (`async () => ({ ... })`)\n\nFix either with `as const` on the literal, or by anchoring the whole object with\n`satisfies app.bsky.graph.getRelationships.$Output`. A\n`Promise<…$Output>` return-type annotation does **not** fix the\nintermediate-variable case — it produces an assignability error instead.\n\n## Errors\n\nThrow an `XRPCError` subclass. Constructors are\n`(message?, errorName?, options?)`; `errorName` should match an error declared\nin the lexicon, and `options` is standard `ErrorOptions` so causes can be\nchained:\n\n```ts\nthrow new InvalidRequestError('Drafts limit reached', 'DraftLimitReached')\nthrow new InvalidRequestError('Unable to resolve handle', undefined, {\n  cause: err,\n})\n```\n\n| Class                       | Status |\n| --------------------------- | ------ |\n| `InvalidRequestError`       | 400    |\n| `AuthRequiredError`         | 401    |\n| `ForbiddenError`            | 403    |\n| `InternalServerError`       | 500    |\n| `MethodNotImplementedError` | 501    |\n| `UpstreamFailureError`      | 502    |\n| `NotEnoughResourcesError`   | 503    |\n| `UpstreamTimeoutError`      | 504    |\n\n`RateLimitExceededError` (429) also exists but takes a `RateLimiterStatus` as its\nfirst argument and is thrown by the rate limiter, not by handlers.\n\nEach subclass overrides `Symbol.hasInstance` to match on the response type\nrather than the prototype chain, so `err instanceof InvalidRequestError` is true\nfor _any_ `XRPCError` with a 400 type, however it was constructed.\n\n`ErrorResult` (`{ status, error?, message? }`) is the non-throwing counterpart,\nreturned by auth verifiers; `excludeErrorResult(v)` converts one back into a\nthrown `XRPCError`.\n\n## Schema accessors inside handlers\n\nThe schema object is in scope — use its accessors instead of NSID string\nliterals (see [STYLE_GUIDE.md](../../../STYLE_GUIDE.md)):\n\n```ts\nconst lxm = app.bsky.actor.getPreferences.$lxm // 'app.bsky.actor.getPreferences'\nconst aud = computeProxyTo(ctx, req, lxm)\npermissions.assertRpc({ aud, lxm })\n```\n\n| Accessor        | Use                                                                  |\n| --------------- | -------------------------------------------------------------------- |\n| `$lxm`          | lexicon method id, for auth scopes and proxy targets                 |\n| `$type`         | record type, e.g. `uri.collection === app.bsky.feed.post.$type`      |\n| `$token`        | a token def's string value (`app.bsky.graph.defs.curatelist.$token`) |\n| `$build({...})` | construct a value with its `$type` set                               |\n\n`$build` is how union members are constructed:\n\n```ts\napp.bsky.graph.defs.notFoundActor.$build({ actor, notFound: true })\n```\n\nFor shapes that carry no `$type`, just annotate or `satisfies` the exported type\n(`com.atproto.server.defs.InviteCode`).\n\n## Type-only references\n\n```ts\ntype Params = app.bsky.feed.getAuthorFeed.$Params\ntype Output = app.bsky.feed.getAuthorFeed.$Output // { encoding, body }\ntype Body = app.bsky.feed.getAuthorFeed.$OutputBody // just the body\ntype PostRecord = app.bsky.feed.post.Main\ntype PostView = app.bsky.feed.defs.PostView\n```\n\n`$OutputBody` is the one you want for helper functions that compute a response\nbody; `$Output` includes the `encoding` wrapper. Inputs mirror this with\n`$Input` / `$InputBody`, and subscriptions use `$Message`.\n\n## Subscriptions\n\nRegister a subscription schema with an async generator. Yield `$build()`-ed\nmessages; the server frames and (unless `validateResponse: false`) validates\neach one. `signal` aborts when the client disconnects — thread it into whatever\nproduces events so the generator unwinds.\n\n```ts\nserver.add(\n  com.atproto.sync.subscribeRepos,\n  async function* ({\n    params,\n    signal,\n  }): AsyncGenerator<com.atproto.sync.subscribeRepos.$Message> {\n    const { cursor } = params\n    const curr = await ctx.sequencer.curr()\n    if (cursor !== undefined && cursor > (curr ?? 0)) {\n      throw new InvalidRequestError('Cursor in the future.', 'FutureCursor')\n    }\n    for await (const evt of outbox.events(cursor, signal)) {\n      yield com.atproto.sync.subscribeRepos.commit.$build({\n        seq: evt.seq,\n        time: evt.time,\n        ...evt.evt,\n      })\n    }\n  },\n)\n```\n\nAnnotating the generator's return type keeps errors on the offending `yield`\nrather than on the whole `server.add()` call. Throwing before the first yield\nrejects the connection with that error. The subscription context is\n`{ params, auth, req, signal }` — and `req` here is a raw Node\n`IncomingMessage`, not an Express `Request`.\n\n`packages/pds/src/api/com/atproto/sync/subscribeRepos.ts` is the only\nsubscription in the repo and the reference implementation.\n\n## File layout\n\nHandlers live under `src/api/<nsid-path>.ts`, one file per method, filename\nmatching the method name in camelCase (the documented exception to the\nkebab-case rule), each default-exporting a registrar:\n\n```\npackages/bsky/src/api/\n  app/bsky/feed/getAuthorFeed.ts   ← export default function (server, ctx) { server.add(...) }\n  com/atproto/identity/resolveHandle.ts\n  index.ts                         ← imports each module and calls it\n```\n\n`src/api/index.ts` is a flat list of imports followed by a flat list of\n`handler(server, ctx)` calls; add new routes in both places.\n\n## Related skills\n\n- **[lex-schema](../lex-schema/SKILL.md)** — the generated schemas passed to\n  `server.add()`, and their `$`-accessors. Needed alongside this skill for\n  almost any route work.\n- **[lex-data](../lex-data/SKILL.md)** — branded strings, `BlobRef`, CBOR, and\n  datetimes at route boundaries.\n- **[lex-client](../lex-client/SKILL.md)** — when the service also calls _out_\n  to other services.\n- **[lexification-server](../lexification-server/SKILL.md)** — migrating an\n  existing service off the legacy `lex gen-server` stack onto the patterns\n  above.\n- **[lex-setup](../lex-setup/SKILL.md)** — lexicon install/build configuration.\n- **[testing](../testing/SKILL.md)** — runner choice and test placement when\n  testing route handlers.\n\n<!-- File: packages/api/docs/moderation.md (Tokens: ~1777 | 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  moderationPrefs: {\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: string\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: AppBskyActorDefs.MutedWord[]\n  hiddenPosts: string[]\n}\n\nexport interface ModerationOpts {\n  userDid: string | 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 `agent.getPreferences()` method:\n\n```typescript\nconst prefs = await agent.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 this static function, though the default is to use Bluesky's moderation:\n\n```typescript\nBskyAgent.configure({\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\nUsers may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed.\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 definition 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.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours).\n\nHere is how to do this:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst agent = new AtpAgent({ service: 'https://example.com' })\n// assume `agent` is a signed in session\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\nmoderatePost(post, {\n  userDid: agent.session.did,\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  moderateFeedGen,\n  moderateUserList,\n  moderateLabeler,\n} from '@atproto/api'\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## 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 `Atproto-Proxy` header:\n\n```typescript\nagent\n  .withProxy('atproto_labeler', 'did:web:my-labeler.com')\n  .createModerationReport({\n    reasonType: 'com.atproto.moderation.defs#reasonViolation',\n    reason: 'They were being such a jerk to me!',\n    subject: { did: 'did:web:bob.com' },\n  })\n```\n\n<!-- File: packages/bsky/AGENTS.md (Tokens: ~2304 | Category: anthropic-skill) -->\n# AppView hydration\n\n- Endpoint-specific viewer block checks must honor `HydrateCtx.skipViewerBlocks` for the trusted moderation service, while preserving viewer block metadata and mute filtering.\n- In `hydration/external.ts`, keep shared types above `ExternalHydrator`; place helpers and their specific types below the class.\n- `ExternalHydrator` generic record lookups decode record bodies without collection-specific validation. Validate against the relevant Lexicon before using typed fields; do not apply parse-mode defaults to stored records.\n- `getRecordsByURI`/`getRecordsByRef` split input into sequential requests of at most `MAX_RECORDS_PER_REQUEST` (the dataplane rejects more than 100 URIs), pairing results positionally per request. Any failed request rejects the whole call; never return partially fetched batches. Bound requests there, not in callers or traversal.\n- `Views.externalRecordView` consumes `HydrationState.externalRecords` (latest versions), `externalRecordBacklinks` (target URI → available source URI samples), and `externalRecordBacklinkCounts`. Hydrate referenced publications, gallery item/photo records, backlink source records, labels, and profiles before building views. Counts are independent of the sampled previews.\n- External view builders keep strict Lexicon validation (`$matches`). Handle third-party records that violate their own published lexicon with a minimal compatibility override (see `lexicons-overrides/README.md`), not by loosening view validation.\n- Streamplace uses a declared HTTP(S) URL or falls back to its AT URI. A stream is active only without `endedAt` and with a `lastSeenAt` within `LIVESTREAM_HEARTBEAT_WINDOW_MS` (2 minutes) of `now`, on either side; record `idleTimeoutSeconds` and `createdAt` are ignored.\n- Atmosphere Explore uses each worker item’s `canonicalUri` as its sole hydration/view root; a missing or invalid root skips the item with no fallback or backfill. With the `AtmosphereExploreArticlesIrisEnable` gate off, worker groups also supply articles; with it on, up to 10 Iris `site.standard.document` roots (fetched concurrently with the worker) are the exclusive article source when the call succeeds with a nonempty articles list: worker articles are ignored and worker location/topic selection does not apply to them. Every list is capped at 10: selected record-backed lists (including Iris articles) are shuffled before hydration and presentation preserves that order; apps are capped at 10 in worker order. Livestream editorial windows constrain visibility before selection and capping without replacing heartbeat-based liveness. App-profile hydration is batched for the selected apps under the same request context, and upstream-only metadata stays out of public cards. Worker ingestion is best-effort per item: malformed banners, groups, and app cards are skipped without failing siblings or the request; only the first 100 entries per worker section are examined. Call Iris directly with request-scoped headers and abort signals, explicitly enabling XRPC response validation even outside debug mode; do not add a separate response parser. Keep article-root validation and the 10-item cap. A successful empty Iris list falls back to worker-curated articles and retains Iris's rec ID; a nonempty Iris list fully removed by root filtering or hydration does not trigger curated fallback. Catch Iris request and response-validation failures (rethrowing request aborts): log a warning and fall back to worker-curated articles with no rec ID; worker failures stay fatal, and the gate-on missing-Iris-client 501 is unchanged. The worker fetch retains its decoded-byte limit and deadline. Each list is an `{ items, recIdStr? }` section; `articles.recIdStr` comes from a successful Iris response, including the empty-list worker fallback, rather than identifying the source of the returned articles. Keep root validation in `getAtmosphereExploreTab.ts`; one shared hydration traversal discovers publications and other dependencies.\n- Exact-ref hydration maps are keyed by the branded `ExactRecordKey` from `genericRecordKey({ uri, cid })`, not URI alone; `parseGenericRecordKey` accepts only those keys and throws on malformed ones. Unavailable records are `null`, and takedowns are excluded unless explicitly requested.\n- Preserve `truncated` from actor backlink lookups: an empty target entry in a truncated batch does not prove the actor has no links.\n- Saved-post hydration selects one CID-pinned root per embed, regardless of collection: the first authored ref, except a legacy publication-first pair may select a document at index 1. Never inspect later refs or fall back from an unavailable selected root. Batch selected roots across post layers through the generic exact-ref traversal. A document's publication is a discovered latest dependency, not a second authored pin; discarded refs must not affect rendering or moderation. A publication selected as the root remains exact.\n- Pass all external URIs to `Hydrator.hydrateExternalViewDependencies`; dispatch by URI collection and validate records inside it. Batch new dependencies through `hydrateEmbedExternalViewFromUris`, sharing a `seenRecordKeys` set (including null lookups) across nested passes. Follow only complete DID-based record URIs.\n- `hydrateEmbedExternalViewFromUris` nulls each generic record (in `externalRecords` and `externalRecordsByRef`, keeping keys) that has an actionable takedown label (unless `includeTakedowns`) or an explicitly `null` owner actor, before dependency expansion. Hiding is per record: never propagate it to targets or dependencies, and leave backlink counts and samples unadjusted.\n- Dependency expansion is bounded by collection-typed edges and per-source sample limits, not a page-wide allowance. Validate direct references and backlink results with `recordUriOf`: complete DID record URI, expected collection, no query/fragment. A source query must never admit a URI of another collection, which could turn a terminal preview into another expanding root. Keep dependencies batched/deduped across posts; direct-only passes issue no backlink or count calls.\n- External hydration traversals share a `seenRecordKeys` set that both wrappers filter against at entry by `genericRecordKey(ref)` (bare URI for latest, `uri@cid` for exact), so each lookup runs once per traversal (null results stay seen). `MAX_EXTERNAL_HYDRATION_PASSES` hard-caps chained batches (root is pass 1; `hydrationPass` is threaded through both methods and never reset). Physical request chunks within a batch consume no passes. At the cap, keep partial state, leave skipped URIs unseen, and warn.\n- External-view counts, backlink pages, and nested dependency batches are best-effort: log failures and leave their state absent (a successful empty page is `[]`), and builders consume partial state, so galleries may be empty. Root fetch errors still propagate. Each recovered failure sets the tracker's `partial` flag, so the traversal reports `partial` unless it was `capped`, which takes precedence. Don't add failure-policy machinery.\n- Thread the optional `hydrationMetrics` tracker through every nested external hydration call (wrappers and `hydrateExternalViewDependencies`). Only the call without one owns the traversal and reports `events.externalHydrationTraversal` once; omitting it in a nested call double-reports. Count lookups at batch scheduling, never from map or seen-set sizes, and keep totals out of metric attributes. `batches` counts logical passes, not RPCs. The reported endpoint is `HydrateCtx.xrpcEndpoint`, an NSID-formatted string that handlers set from their method's `$lxm`. Discovery counts each inspected ref as resolved if available after moderation; the first discovery call's refs are roots (tracked by a flag, not pass number), later ones dependencies.\n- `hydrateEmbedExternalViewFromUris` and `hydrateEmbedExternalViewFromRefs` are thin wrappers over one pipeline and one collection dispatch. Latest lookups are identified by URI (`externalRecords`); exact versions by `genericRecordKey({ uri, cid })` (`externalRecordsByRef`). Both identities share `seenRecordKeys`, and one never implies the other; a latest fetch does not mark its returned version as an exact lookup. Dependency discovery takes `ItemRef[]` and inspects every pinned version; nested dependencies are authored URIs and always go through the URI wrapper. A successful lookup with the same URI and CID may replace an unavailable pinned version; a different CID never satisfies it.\n- External hydration takes `ExternalHydrationOptions` (`hydration`, plus the shared `seenRecordKeys`, `hydrationPass`, `hydrationMetrics`). `'basic'` keeps renderable content (including gallery item samples and photos, publication discovery), owner profiles/labels/moderation and backlink counts, but skips social-preview samples and their records, participant profiles and labels. `'detailed'` (default) adds those previews. Collection rules in `discoverExternalViewDependencies` decide this, never callsites; counts are scheduled for every target even with no sample queries. One level applies to a whole traversal: pass it through every nested call. `hydratePosts` (feeds) uses basic. The level never changes identity, pin or latest-dependency semantics.\n\n# External embed validation utilities\n\n- `util/external/standard-site.ts` (`getStandardSiteRecordsForUri`, `validateStandardSiteRecordsForUrl`) and `util/external/index.ts` (`isExternalRecordUrlSafe`) are pure and synchronous over caller-supplied hydrated records, URLs, and a domain list: no I/O, config reads, mutation, or view construction.\n\n# `app.bsky.embed.getEmbedExternalView`\n\n- The handler normalizes the request to one record URI before hydration: an explicit `canonicalUri` always wins (ignoring `uris`, with no fallback); otherwise the legacy `uris` input selects the first `site.standard.document`, else the first `site.standard.publication`. Only the one URI is hydrated; dependencies come from existing hydration.\n- Validate before building any view: Standard.site records via `getStandardSiteRecordsForUri` + `validateStandardSiteRecordsForUrl`, other records via `isExternalRecordUrlSafe(url, cfg.externalRecordsSafeDomains)`. Renderability is never the trust test.\n- Validated records return `data` and `associatedRefs`/`associatedRecords`; failed validation returns only `unverifiedAssociatedRefs`/`unverifiedAssociatedRecords` and builds no view. The legacy `view` is returned for Standard.site records only. Both outputs and the legacy `view.external.associatedRefs` use the same index-aligned persisted subset, canonical first.\n- Render-time hydration and persistence are separate. Hydration may include engagement records (recommendations, subscriptions, favorites) that back `likers`/`subscribers` previews; that is intentional. `collectAssociations` persists the canonical record when available (whatever its collection) plus only the extra collections opted in by `PERSISTED_DEPENDENCY_COLLECTIONS` (document → publication; gallery → gallery item and photo), to keep post records slim. It is not an engagement blacklist. A new persisted dependency collection needs an explicit entry; do not change hydration or views for this.\n\n<!-- File: packages/lex/lex-resolver/AGENTS.md (Tokens: ~347 | Category: anthropic-skill) -->\n# AGENTS.md\n\n## Lexicon codegen: exception to the per-package `lexicons.json` convention\n\nThis package does **not** follow the repo-wide lexicon-management convention (a\nper-package `lexicons.json` manifest resolved with `lex install`; see\n[CONTRIBUTING → \"Working with lexicons\"](../../../CONTRIBUTING.md#working-with-lexicons)\nand the `lex-setup` skill). It has no `lexicons.json` and compiles the canonical\nschemas straight from the repo-root [lexicons/](../../../lexicons/):\n\n```\n\"codegen:lex\": \"lex-builder build --lexicons ../../../lexicons --out ./src/lexicons --clear --lib @atproto/lex-schema --include com.atproto.sync.getRecord --include com.atproto.lexicon.schema\"\n```\n\n**Why:** the installer (`@atproto/lex-installer`) depends on `@atproto/lex-resolver`,\nso this package cannot depend on the installer to manage a manifest without\ncreating a dependency cycle. It only needs canonical (`com.atproto.*`) schemas,\nwhich live in the root `lexicons` directory anyway, so compiling them directly is\nsufficient.\n\n**Do not** add a `lexicons.json`, `@atproto/lex`, or `@atproto/lex-installer`\nhere, and do not \"migrate\" this `codegen:lex` to the `lex install` form — keep the\n`lex-builder build --lexicons ../../../lexicons --include …` shape. Only\n`/src/lexicons` is gitignored (generated); there is no resolved `./lexicons`\ndirectory to manage.\n\n<!-- File: packages/oauth/oauth-provider-ui/CLAUDE.md (Tokens: ~3455 | Category: anthropic-skill) -->\n# oauth-provider-ui\n\nUI for the OAuth provider's authorization & account-management screens. React 19, TanStack Router, Tailwind 4, shadcn/ui on Base UI, native-constraint forms on `@base-ui/react/form`, Lingui i18n.\n\n## Imports\n\n**Import from `react` by name — `import { useState, type ComponentProps }`.**\nNever `import * as React`, and never reach for the `React.*` global without an\nimport at all (`@types/react` declares it, so `React.ComponentProps` compiles\nwith no import and silently introduces a third style).\n\nThe exception is `components/ui/*`, which is shadcn CLI output and stays\nbyte-faithful — the registry emits `import * as React`, and rewriting it would\nput a diff in every file on the next `shadcn add`. The boundary is the\ndirectory: inside it, whatever the registry emits; everywhere else, named\nimports.\n\n## Verification\n\nType-check from the package directory:\n\n```bash\ncd packages/oauth/oauth-provider-ui\npnpm exec tsc --build tsconfig.json\npnpm test\npnpm run build:ui\n```\n\n**Run eslint from the repo root, not the package.**\n\n```bash\ncd /path/to/atproto\npnpm exec eslint --fix 'packages/oauth/oauth-provider-ui/src/**/*.tsx'\n```\n\nPrettier's Tailwind plugin reorders class strings. Accept its order.\n\n## Looking at the UI\n\n`pnpm dev:ui` serves all four entry pages on port 5174 against `src/mock-api.ts`\n— no PDS, no docker, no dev-env. Prefer it over booting the dev environment.\n\nThe account page starts signed out. The account rows in the picker are not\n`<button>`s — click `[aria-label^=\"Sign in as\"]`. The router uses real paths, not\nhashes, so navigate by clicking sidebar links rather than setting\n`location.hash`.\n\nEvery path under `/account` serves the account page, as it does behind the PDS\n(the `mock-account-paths` plugin in `vite.config.js`), so deep links and\nrefreshes work. That is what makes the mock usable for anything to do with URL\nstate — `account-page.html` must therefore keep its imports absolute and leave\nthe path alone when it is already under `/account/`.\n\n## Routing\n\n**File-based.** `src/routes/` is the route tree, and `src/routeTree.gen.ts` is\ngenerated from it by `@tanstack/router-plugin` on every dev run and build. The\ngenerated file is committed; don't edit it, and don't add it to a lint or format\npass (it is already in `.prettierignore`). `autoCodeSplitting` gives every route\ncomponent its own chunk — no `lazyRouteComponent` by hand.\n\nNaming follows TanStack's conventions: `__root.tsx`, `index.tsx` for a path's\nown page, a `$` prefix for params, `route.tsx` for a directory's layout, and a\n`-` prefix for files that are colocated content rather than routes (the About\npage's localized copies live in `-about/`).\n\n**Guards go in `beforeLoad`, never in render.** `/account` decides where the\ndevice's sessions belong (welcome / picker / the selected account) and\n`/account/u/$accountId` resolves the account the URL names, each throwing\n`redirect()`. `beforeLoad` returns `{ session }`, so descendants read the\naccount from `Route.useRouteContext()` instead of resolving it again. Rendering\na `<Navigate>` to gate a page reintroduces a render-then-bounce and a window\nwhere children fetch as the wrong account.\n\n**Router context** carries what the guards and loaders need but can't reach\nthrough React: `auth` (the session store), `api`, and `queryClient`. It is\nsupplied at `<RouterProvider context={…}/>` in `account-page.tsx`.\n\nTwo consequences that are easy to get wrong:\n\n- `auth` is `SessionContextType.store`, whose fields are **getters**, not a\n  render snapshot. A guard runs during a navigation that is usually dispatched\n  from the same event handler that just mutated the session, so a captured\n  value would be one render stale and the guard would reject the account it was\n  just sent to.\n- Changing the context does not re-run the guards. `account-page.tsx`\n  invalidates the router when the sessions change, which is what makes signing\n  out move the user with no navigation wired into the action.\n\nData pages load through the route: `loader` calls\n`queryClient.ensureQueryData(…QueryOptions(api, …))` and the component reads the\nsame query with `useSuspenseQuery`, so there is no in-component loading state.\nFailures surface through the route's `errorComponent` (whose `reset` retries).\n\nThe third-party consent flow has no router at all — it is a linear wizard and\nkeeps `AuthenticationProvider`.\n\nA link's `to` is a path _template_ (`/account/u/$accountId/manage`) — it never\nequals `location.pathname`. Test the current page with `useMatchRoute`\n(`useIsCurrentTarget` in `AccountShell`) and mark active links with\n`activeOptions={{ exact: true }}`; without `exact`, the account home matches\nevery page below it.\n\n## Components\n\n- `components/ui/*` — shadcn primitives, style `base-nova`, on `@base-ui/react`.\n  **Generated by the shadcn CLI, not hand-written.** Regenerate into a scratch\n  project and copy across, applying only these rewrites: `@/lib/utils` →\n  `#/lib/utils.ts`, `@/registry/…/ui/x` → `#/components/ui/x.tsx`, and drop any\n  `'use client'` directive (a Next.js RSC marker; this app is Vite). Keep these\n  files byte-faithful to upstream so future `shadcn add` stays mergeable — put\n  deviations in wrapper components instead. Base UI composes via\n  `render={<El/>}`, not a `asChild`-style prop.\n  The registry's components assume `@custom-variant` bridges that ship in the\n  style's `globals.css`, so copying `components/ui/*` alone is not enough — see\n  the orientation bridge in `src/style.css`. `Separator` silently rendered at\n  zero width/height until it was added.\n  Every file in there is a real registry entry — if `shadcn add <name>` under\n  this style returns an item with no files, the component is ours and belongs\n  outside this directory.\n- `components/forms/*` — `FormShell` (the form frame: action row, submit/cancel/\n  back, root error), `fields/*` (one wrapper per input type), plus\n  `AsyncButton`, `CopyButton`, `RequestCodeButton`, `SignUpWizard`.\n- `components/dialogs/*` — `DialogShell` (frame).\n- `components/feedback/*` — `Notice`, `ErrorNotice`, `ErrorCard`,\n  `ErrorDetails`, `PasswordStrength`.\n- `components/identity/*` — account/client avatars, names, identifiers, the\n  account menu.\n- `components/layouts/*` — `AuthShell` (the centred auth card) and\n  `AccountShell` (sidebar + content). These two own the whole page frame,\n  including the `<title>`, so they must never nest.\n- Pages under `src/pages/` and the four `*-page.tsx` entry points own contexts,\n  TanStack Query and routing.\n\n## Forms\n\nForms are native: `FormShell` (on `@base-ui/react/form`) plus one field wrapper\nper input. Validation is HTML constraint attributes on the controls —\n`required`, `type`, `pattern`, `minLength` — so an invalid submit is blocked by\nthe browser with its own bubble, in the browser's locale. There is no form\nlibrary, no schema layer, and no store: the DOM is the source of truth, and\n`FormShell` reads the values off the element with `FormData`.\n\n```tsx\n<FormShell<Values> onSubmit={(values, signal) => handler(values, signal)}>\n  <EmailField name=\"email\" required label={<Trans>Email</Trans>} />\n</FormShell>\n```\n\nShared regexes for `pattern` attributes live in `src/lib/form-patterns.ts`.\nNote `pattern` sources are implicitly anchored — no `^`/`$` — and compiled with\nthe `v` flag, under which a literal `-` in a character class must be escaped\n(`[a-z0-9\\-]`). A pattern that fails to compile is silently ignored by the\nbrowser (Chrome logs \"Pattern attribute value … is not a valid regular\nexpression\"), disabling the constraint. The pds `oauth.test.ts` asserts\n`validity.patternMismatch` on the sign-in identifier to catch this in a real\nbrowser.\n\nBase UI rules this layer depends on — each one was a real, browser-only bug:\n\n- **Values must come from `FormData`, never from `onFormSubmit`'s argument.**\n  Base UI only passes values for controls registered through `Field.Root`; a\n  plain named input (a checkbox, a hidden input) is silently absent. `FormShell`\n  already does this — don't \"simplify\" it back.\n- **`Form` defaults to `noValidate`.** `FormShell` re-enables the browser's\n  gate; without it, any control outside a `Field.Root` is validated by no one.\n- **A `Field.Root` binds every descendant control to its own name**, even over\n  an explicit `name` prop. A field composed of two controls (`NewHandleField`)\n  must opt out of `Field` entirely.\n- **A `disabled` control is omitted from form values.** Use `readOnly` when the\n  value must still submit.\n- **`type=\"email\"` accepts `user@host` with no dot** — `EmailField` carries a\n  `pattern` requiring a TLD on top.\n\n`FormShell` submits through `useAsyncAction`, which handles abort-on-unmount,\nabort-on-supersede and error retention. Errors render via `ErrorNotice` +\n`apiErrorParser`, so typed OAuth error payloads keep their user-facing messages.\nFor callers that must survive remounting (the sign-up wizard's back/forward),\n`onValues` mirrors the form's values on every edit.\n\n**Reserve space for anything that appears conditionally** next to an\ninteractive control — a layout shift between mousedown and mouseup lands the\nclick on a different element and silently swallows it.\n\n### Field names are a public contract\n\nThe `name` attribute on each control is selected on by the pds e2e suite and by\npassword managers. These names are owned by this package and must not be\nrenamed: `identifier`, `password`, `remember`, `token`, `emailOtp`, `email`,\n`handle`, `domain`, `inviteCode`.\n\nControls are named after the API field they feed (`identifier`, `token`,\n`emailOtp`), so values pass through without remapping. Keep it that way when\nadding one. Identifier fields also keep `autoComplete=\"username\"`, which is\nwhat marks them for password managers.\n\n## i18n (Lingui)\n\n- `.po` files under `src/locales/*` are generated; don't hand-edit except to add\n  French translations.\n- After any `.tsx` edit run `pnpm i18n`, then **check the diff for added or\n  removed `msgid`s**. Only line-reference churn is expected. This check has\n  caught two bugs that nothing else would have.\n- msgids are the English source strings, so changing a string orphans five\n  locales. `<Trans>` placeholders are positional (`<0>`, `<1>`) and derive from\n  JSX nesting — adding or reordering an element inside a `<Trans>` renumbers them\n  and changes the msgid. Recompose _around_ `<Trans>` blocks. Expression\n  placeholders are named by the identifier — `{minLength}` and `{MIN_LENGTH}`\n  are different msgids — so alias a renamed variable back rather than touching\n  the string.\n- **Never pass `t` as a prop.** The macro only transforms `` t`…` `` in a scope\n  that imports `useLingui` from `@lingui/react/macro`; passing `t` down leaves\n  the template uncompiled and the string untranslated, and it silently vanishes\n  from the catalogs.\n- A msgid that disappears and returns loses its translation. After extraction,\n  check the _count_ of untranslated entries, not just the msgid diff.\n- Fill in French only; other locales are translated externally.\n\n## The pds e2e contract\n\n`packages/pds/tests/{oauth,account-manager}.test.ts` drive this UI by text and\ntag, so markup choices are load-bearing. The test files themselves are the\nauthoritative record of which text sits in which element. The rules:\n\n- actions the tests click must be real `<button>`s\n- body copy in `<p>`, page/section headings in the tag the test names\n  (`h2`/`h3`), identifiers and radio labels in `<span>`, code samples in `<code>`\n- dialog content keeps `role=\"dialog\"` and a `button[type=\"submit\"]`\n- `CardDescription` and `AlertDescription` render `<div>`, so wrap asserted copy\n  in an explicit `<p>`. **`DialogDescription` does not** — Base UI's\n  `Dialog.Description` is already a `<p>`, and wrapping it produced `<p><p>`,\n  which React reports as invalid nesting. Check the primitive's rendered element\n  before adding a wrapper rather than assuming they match.\n\nDialogs must cap their height (`DialogShell` does): Base UI's `DialogContent` is\n`fixed` with no height limit, and a tall dialog on a short viewport puts its\nbuttons out of reach with no way to scroll to them.\n\n## Branding\n\n`CustomizationData` supplies the logo, name, footer links and\n`availableUserDomains`. Colours arrive separately, at runtime: the provider\ninjects `--branding-color-*` variables (see\n`oauth-provider/src/customization/build-customization-css.ts`), and the\n\"Runtime branding\" section of `src/style.css` maps `primary`, `error`, `info`,\n`warning` and `success` onto the shadcn tokens. Each is one injected RGB that\nconsumers tint with opacity modifiers, not the old multi-shade hue palette.\nWhen a deployment configures nothing the tokens fall back to the pre-redesign\nstock palette (a purple primary) — deliberately not Bluesky's, so an\nuncurated PDS is not mistaken for one.\n\n`branding.background` customises the auth screen's page area (behind the card,\nnot the card itself). It carries a light and a dark image URL; the provider\ninjects `--branding-background-{light,dark}-image` (wrapped in `url(...)`), and\nthe `.auth-background` rule in `src/style.css` paints that image over the neutral\n`muted` base, picking the mode by `prefers-color-scheme`. Unset, it falls back to\nthe plain `muted` surface it always was. The card keeps its own opaque surface so\ncopy stays legible over any background.\n\nThe mock pages inject the same variables a branded deployment would, so\n`pnpm dev:ui` demonstrates branding without a PDS.\n\n## Utility types\n\n`Override<A, B>` (from `#/lib/util.ts`) is the standard prop-extension pattern:\n\n```tsx\ntype Props = Override<JSX.IntrinsicElements['button'], { icon: LucideIcon }>\n```\n\nIcons are `lucide-react`.\n\n## Linting quirks\n\nESLint requires unused vars/args to match `/^_/u`. Drop the destructure rather\nthan prefixing when the prop is genuinely unused.\n\n<!-- File: packages/ozone/AGENTS.md (Tokens: ~625 | Category: anthropic-skill) -->\n# Ozone moderation inbox\n\n- Reject wildcard `queryLabels` requests before accessing the DB unless `OZONE_LABEL_QUERY_WILDCARDS_ENABLED` is enabled; exact queries remain available.\n- Execute cancellable listing SQL with the HTTP signal and Kysely's `inflightQueryAbortStrategy: 'cancel query'`; a signal alone leaves PostgreSQL work running.\n- Reporter statistics seek recent non-appeal reports by reporter/time and look up distinct linked action IDs by primary key; never join reports to subject event history.\n\n- Read previews share `inboxViewerDid`; writes always use the authenticated DID.\n- Notification preference writes atomically patch only provided categories. Unset category columns inherit the retained push column to preserve existing opt-outs without a backfill; preferences do not suppress in-app inbox activity.\n- Produce moderation notifications from the common `ModerationService.logEvent` path so daemon actions are covered. Keep source snapshots in the source transaction, and isolate optional queries/inserts with `runNotificationWork` savepoints; a caught SQL error alone leaves PostgreSQL's transaction aborted.\n- Batch report notifications and skip non-transition activities before querying. Viewer responses never expose moderator note text.\n- Inbox appeal state and filters prioritize active reports; eligibility considers inbox appeal reports, including reopened and pre-cutoff history. Serialize inbox appeal submissions by subject; subject reviewState and appealed flags are not synchronized by report activities. Unread includes appeal report updates, independently of the requested sort field. Apply filters before pagination.\n- Appeal outcomes come from the current close activity and its linked corrective event, never merely nonempty actionEventIds (which also contain the appealed action). No-action appeal closures record denied in activity metadata; unknown historical closures omit the outcome. Resolution notifications snapshot the outcome.\n- Seek the latest appeal per requested subject with an indexed lateral LIMIT before joining its closure and resolution event; outcome work must be bounded by the page size, not appeal history.\n- Read `NEXT_PUBLIC_STRIKE_SUSPENSION_CONFIG` through configuration. The second and third sorted strike thresholds determine warning and atRisk; retain the full hours map, including Infinity.\n- Use explicit predicates for partial indexes on heavy tables. Report pagination seeks report.reporterDid/time indexes, validates source ownership before LIMIT by lateral primary-key lookup, and materializes only the page before hydration. Reporter backfill/catch-up is a deployment prerequisite.\n- OZONE_INBOX_START_AT bounds history by inclusive creation time before pagination/aggregation, including details and notification targets. Express the cutoff on the sort/seek index's own column (report updatedAt pages also bound `updatedAt`), join per-DID aggregates to statuses on indexed columns (`recordPath`), and bind the viewer DID as a constant in correlated `EXISTS` checks: PostgreSQL may hash such subplans and drop correlation. Keep live standing/enforcement and historical appeal eligibility authoritative; only display post-cutoff appeal history.\n- Migrations that use report.reporterDid must run after _20261002T000000000Z. Pre-create large indexes concurrently in production and verify they are valid and ready before running transactional migrations.\n\n<!-- File: packages/repo/AGENTS.md (Tokens: ~276 | Category: anthropic-skill) -->\n# packages/repo\n\n## MST traversals MUST call `markVisited`\n\nEvery code path that can visit more than one child of an MST node — full-tree\nwalks, diffs, block collection; recursive or iterative; in `src/mst/mst.ts`,\n`walker.ts`, `diff.ts`, or new code anywhere — **MUST**:\n\n1. create a fresh `CidSet` per traversal (never share one between traversals),\n   and\n2. call `node.markVisited(seen)` on every `MST` node it enters, before reading\n   that node's entries (`getEntries`, `atIndex`, …).\n\nThis is a requirement, not an optimization: a traversal that skips it is a bug\neven if every test passes. When adding a traversal, add a matching case to\n`tests/mst-dag.test.ts`.\n\nExempt: lookups that follow a single path (one recursive call per level, e.g.\n`cidsForPath`, `proofForKey`), which are bounded by tree depth, and walks over\nin-memory nodes built by local mutations (outdated pointers, e.g. `serialize`),\nsince nodes loaded from storage never have one.\n\n`MST.markVisited` throws `VisitedCidError` on a repeated node and skips nodes\nwith an outdated pointer (not yet serialized).\n\n<!-- File: packages/repo/CLAUDE.md (Tokens: ~11 | Category: anthropic-skill) -->\n# CLAUDE.md\n\n@AGENTS.md\n\n<!-- File: packages/api/README.md (Tokens: ~2725 | Category: plugin-manifest) -->\n# ATP API\n\nThis API is a client for ATProtocol servers. It communicates using HTTP. It includes:\n\n- ✔️ APIs for ATProto and Bluesky.\n- ✔️ Validation and complete typescript types.\n- ✔️ Session management.\n- ✔️ A RichText library.\n\n> [!Note]\n>\n> We recommend using [lex](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), our type-safe SDK with Lexicon code generation, for all new projects.\n\n## Getting started\n\nFirst install the package:\n\n```sh\nyarn add @atproto/api\n```\n\nThen in your application:\n\n```typescript\nimport { Agent, CredentialSession } from '@atproto/api'\n\nconst session = new CredentialSession(new URL('https://bsky.social'))\nawait session.login(account)\nconst agent = new Agent(session)\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 `Agent`\nclass.\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 { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api'\n\n// Configure connection to the server with authentification\nconst account: AtpAgentLoginOpts = {\n  identifier: 'your.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n}\n\nasync function authenticate(account: AtpAgentLoginOpts): Promise<Agent> {\n  const session = new CredentialSession(new URL('https://example.com'))\n  await session.login(account)\n  const agent = new Agent(session)\n  return agent\n}\n\n;(async () => {\n  console.log('Authenticating...')\n  const agent = await authenticate(account)\n  console.log(`Authenticated as from: ${agent.sessionManager.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\n`OAuthSession` instance that can be used to instantiate an `Agent` (from\n`@atproto/api`). Here is an example restoring a previously saved session:\n\n```typescript\nimport { Agent } from '@atproto/api'\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 api Agent using an OAuthSession\nconst agent = new Agent(oauthSession)\n```\n\n### API calls\n\nThe agent includes methods for many common operations, including:\n\n```typescript\n// The DID of the user currently authenticated (or undefined)\nagent.did\nagent.accountDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait agent.getTimeline(params, opts)\nawait agent.getAuthorFeed(params, opts)\nawait agent.getPostThread(params, opts)\nawait agent.getPost(params)\nawait agent.getPosts(params, opts)\nawait agent.getLikes(params, opts)\nawait agent.getRepostedBy(params, opts)\nawait agent.post(record)\nawait agent.deletePost(postUri)\nawait agent.like(uri, cid)\nawait agent.deleteLike(likeUri)\nawait agent.repost(uri, cid)\nawait agent.deleteRepost(repostUri)\nawait agent.uploadBlob(data, opts)\n\n// Social graph\nawait agent.getFollows(params, opts)\nawait agent.getFollowers(params, opts)\nawait agent.follow(did)\nawait agent.deleteFollow(followUri)\n\n// Actors\nawait agent.getProfile(params, opts)\nawait agent.upsertProfile(updateFn)\nawait agent.getProfiles(params, opts)\nawait agent.getSuggestions(params, opts)\nawait agent.searchActors(params, opts)\nawait agent.searchActorsTypeahead(params, opts)\nawait agent.mute(did)\nawait agent.unmute(did)\nawait agent.muteModList(listUri)\nawait agent.unmuteModList(listUri)\nawait agent.blockModList(listUri)\nawait agent.unblockModList(listUri)\n\n// Notifications\nawait agent.listNotifications(params, opts)\nawait agent.countUnreadNotifications(params, opts)\nawait agent.updateSeenNotifications()\n\n// Identity\nawait agent.resolveHandle(params, opts)\nawait agent.updateHandle(params, opts)\n\n// Legacy: Session management should be performed through the SessionManager\n// rather than the Agent instance.\nif (agent instanceof AtpAgent) {\n  // AtpAgent instances support using different sessions during their lifetime\n  await agent.createAccount({ ... }) // session a\n  await agent.login({ ... }) // session b\n  await agent.resumeSession(savedSession) // session c\n}\n```\n\n### Validation and types\n\nThe package includes a complete types system which includes validation and type-guards. For example, to validate a post record:\n\n```typescript\nimport { AppBskyFeedPost } from '@atproto/api'\n\nconst post = {...}\nif (AppBskyFeedPost.isRecord(post)) {\n  // typescript now recognizes `post` as a AppBskyFeedPost.Record\n  // however -- we still need to validate it\n  const res = AppBskyFeedPost.validateRecord(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 { RichText } from '@atproto/api'\n\n// creating richtext\nconst rt = new RichText({\n  text: 'Hello @alice.com, check out this link: https://example.com',\n})\nawait rt.detectFacets(agent) // automatically detects mentions and links\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: new Date().toISOString(),\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 { moderatePost } from '@atproto/api'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await agent.getPreferences()\nconst labelDefs = await agent.getLabelDefinitions(prefs)\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: agent.session.did,\n  moderationPrefs: 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 include in feeds\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 methods above are convenience wrappers. It covers most but not all available methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can use them on the agent as well:\n\n```typescript\nconst res1 = await agent.com.atproto.repo.createRecord({\n  did: alice.did,\n  collection: 'app.bsky.feed.post',\n  record: {\n    $type: 'app.bsky.feed.post',\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n})\nconst res2 = await agent.com.atproto.repo.listRecords({\n  repo: alice.did,\n  collection: 'app.bsky.feed.post',\n})\n\nconst res3 = await agent.app.bsky.feed.post.create(\n  { repo: alice.did },\n  {\n    text: 'Hello, world!',\n    createdAt: new Date().toISOString(),\n  },\n)\nconst res4 = await agent.app.bsky.feed.post.list({ repo: alice.did })\n```\n\n### Non-browser configuration\n\nIf your environment doesn't have a built-in `fetch` implementation, you'll need\nto provide one. This will typically be done through a polyfill.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation, you can do so by\ninstantiating the sessionManager with a custom fetch implementation:\n\n```typescript\nimport { AtpAgent } from '@atproto/api'\n\nconst myFetch = (input: RequestInfo | URL, init?: RequestInit) => {\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 agent = new AtpAgent({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\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\n<!-- File: packages/aws/README.md (Tokens: ~16 | Category: plugin-manifest) -->\n# AWS KMS\n\nA Keypair-compatible wrapper for AWS KMS.\n\n<!-- File: packages/bsky/README.md (Tokens: ~261 | Category: plugin-manifest) -->\n# @atproto/bsky: Bluesky AppView Service\n\nTypeScript implementation of the `app.bsky` Lexicons backing the https://bsky.app microblogging application.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsky)](https://www.npmjs.com/package/@atproto/bsky)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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\n<!-- File: packages/bsky/src/feature-gates/README.md (Tokens: ~588 | Category: plugin-manifest) -->\n# Feature Gates\n\nA thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag\nevaluation and experiment tracking.\n\n## Usage\n\nFeature gates are accessible on `HydrationCtx` as `features`. A default \"scope\"\nis defined in `Hydrator.createContext`, which is called by every request\nhandler. The default scope supplies anonymous `deviceId` and `sessionId`\nidentifiers for targeting unauthenticated users.\n\nFeature gates can be checked via the following methods.\n\n```typescript\n// check a single gate\nconst enabled = ctx.features.checkGate(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n\n// check multiple gates\nconst gates = ctx.features.checkGates([\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n  ctx.features.Gate.SomeOtherGate,\n])\nconst enabled = gates.get(\n  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,\n)\n```\n\n### User Context\n\nTo accurately gate features by user, we need additional context about that user.\nFor most use cases, we get this data from within the request handlers.\n\nHere, the `features` client will be scoped to the current user, allowing for\naccurate gate checks throughout the request lifecycle.\n\n```typescript\nconst features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(\n  ctx.featureGatesClient.parseUserContextFromHandler({\n    viewer,\n    req,\n  }),\n)\n\nconst hydrateCtx = await ctx.hydrator.createContext({\n  labelers,\n  viewer,\n  features,\n})\n```\n\n> [!NOTE]\n> Although `ctx.featureGatesClient` also has a `checkGates` method, the\n> intention is to use the `ScopedFeatureGatesClient` returned by `scope()` for\n> gate checks within the application.\n\nYou can also pass the user context directly to `checkGate` or `checkGates`. This\noverrides any default scope set on the client, allowing for flexibility in cases\nwhere user context is only available at the point of gate evaluation.\n\n```typescript\nconst enabled = ctx.featureGatesClient.checkGate(\n  ctx.featureGatesClient.Gate.ImageFeatureEnabled,\n  { did: imageAuthor.did },\n)\n```\n\n### Adding Gates\n\nSee `gates.ts` and add them to the enum. You can optionally prevent metrics from\nbeing emitted for a gate by adding the gate to the `IGNORE_METRICS_FOR_GATES`\nset.\n\n## Metrics\n\nCheck out the Growthbook docs for more info here. Basically, every feature eval\nfires the `onFeatureUsage` callback. Any feature that is part of an experiment\n_will also fire the `trackingCallback` callback._\n\nFor some use cases, this can be noisy and/or performance intensive, so make use\nof `IGNORE_METRICS_FOR_GATES` to silence metrics for specific gates.\n\n<!-- File: packages/bsync/README.md (Tokens: ~261 | Category: plugin-manifest) -->\n# @atproto/bsync: Synchronizing Service for the Bluesky AppView\n\nThis is an optional service that may be used to synchronize certain state between otherwise independent AppViews.\n\n[![NPM](https://img.shields.io/npm/v/@atproto/bsync)](https://www.npmjs.com/package/@atproto/bsync)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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\n<!-- File: packages/common-web/README.md (Tokens: ~249 | Category: plugin-manifest) -->\n# @atproto/common-web\n\nShared TypeScript code for other `@atproto/*` packages, which is web-friendly (runs in-browser).\n\n[![NPM](https://img.shields.io/npm/v/@atproto/common)](https://www.npmjs.com/package/@atproto/common-web)\n[![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)\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\n<!-- ... +45 more modular skills available via /api/skills/bluesky-social/atproto?path={path} -->\n</yakaai_skills>"}