atproto Documentation

Social networking technology created by Bluesky

9,709 stars TypeScript TypeScript 58 files · ~65,635 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy atproto on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name atproto -p 5433:5433 --restart always postgres:14.4-alpine
Configured Services & Ports
⚙️ db_test
image: postgres:14.4-alpine
🔌 Ports: 5433:5432
⚙️ db
image: postgres:14.4-alpine
🔌 Ports: 5432:5432
⚙️ redis_test
image: redis:7.0-alpine
🔌 Ports: 6380:6379
⚙️ redis
image: redis:7.0-alpine
🔌 Ports: 6379:6379
docker-compose.yml
yaml (docker-compose.yml)
services:
  # An ephermerally-stored postgres database for single-use test runs
  db_test: &db_test
    image: postgres:14.4-alpine
    # We allow lots of connections to allow fast running computers to run
    # tests in parallel without running into connection limits.
    command: -c 'max_connections=1000'
    environment:
      - POSTGRES_USER=pg
      - POSTGRES_PASSWORD=password
    ports:
      - '5433:5432'
    # Healthcheck ensures db is queryable when `docker-compose up --wait` completes
    healthcheck:
      test: 'pg_isready -U pg'
      interval: 500ms
      timeout: 10s
      retries: 20
  # A persistently-stored postgres database
  db:
    <<: *db_test
    ports:
      - '5432:5432'
    healthcheck:
      disable: true
    volumes:
      - atp_db:/var/lib/postgresql/data
  # An ephermerally-stored redis cache for single-use test runs
  redis_test: &redis_test
    image: redis:7.0-alpine
    ports:
      - '6380:6379'
    # Healthcheck ensures redis is queryable when `docker-compose up --wait` completes
    healthcheck:
      test: ['CMD-SHELL', '[ "$$(redis-cli ping)" = "PONG" ]']
      interval: 500ms
      timeout: 10s
      retries: 20
  # A persistently-stored redis cache
  redis:
    <<: *redis_test
    command: redis-server --save 60 1 --loglevel warning
    ports:
      - '6379:6379'
    healthcheck:
      disable: true
    volumes:
      - atp_redis:/data
volumes:
  atp_db:
  atp_redis:

Technical Documentation: bluesky-social/atproto

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

AT Protocol Reference Implementation (TypeScript)

Welcome friends!

This repository contains Bluesky's reference implementation of AT Protocol, and of the app.bsky microblogging application service backend.

About AT Protocol

The Authenticated Transfer Protocol ("ATP" or "atproto") is a decentralized social media protocol, developed by Bluesky Social PBC. Third-party software can be as seamless as first-party: custom feeds, federated services, and alternative clients are all built against the same APIs, so developers are never locked out of the ecosystems they help build. Learn more at:

The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is app.bsky.*.

What is in here?

Lexicons: for both the com.atproto.* and app.bsky.* are canonically versioned in this repo, for now, under ./lexicons/. These are JSON files in the Lexicon schema definition language, similar to JSON Schema or OpenAPI.

TypeScript Packages: everything under ./packages/ is published to npm under the @atproto/* scope (except for the shared utilities in ./packages/internal/, which use the @atproto-labs/* scope). Each package has its own README; packages/README.md lists them all. The short version, if you're new here:

  • Building an app or bot? Start with @atproto/lex — the type-safe SDK that generates TypeScript from Lexicon schemas and gives you an HTTP (XRPC) client. The rest of ./packages/lex/ contains its building blocks (data model, JSON/CBOR encoding, schema validation, server routing), which you rarely need to install directly. The older @atproto/api client still exists and is still used in parts of this repo, but new code should prefer @atproto/lex.
  • Adding "Sign in with Bluesky"? The OAuth client and server implementations live in ./packages/oauth/ — pick the client variant matching your runtime (browser, node, expo).
  • Implementing atproto itself? The protocol primitives are split into focused packages: identifier parsing (syntax), DID/handle resolution (identity, did), signing (crypto), repository & Merkle Search Tree (repo), and firehose consumption (sync).
  • Everything else is either a service implementation (see below), shared internal glue (common, common-web, ./packages/internal/), or dev tooling (dev-env, dev-infra).

TypeScript Services: each has its implementation in packages/<name> and a thin runtime wrapper in services/<name>.

  • pds: "Personal Data Server", hosting repo content for atproto accounts. See bluesky-social/pds for directions on self-hosting.
  • bsky: AppView implementation of the app.bsky.* API endpoints. Running on main network at api.bsky.app.
  • bsync: Internal synchronization service used by the AppView for cross-service state such as mutes and notifications.
  • ozone: Moderation service implementing the tools.ozone.* API.

What is not in here?

The source code for the Bluesky Social client app (for web and mobile) can be found at bluesky-social/social-app.

Go programming language source code is in bluesky-social/indigo, including the BGS implementation.

Contributions

While we do accept contributions, we prioritize high quality issues and pull requests. See CONTRIBUTING.md for the developer quickstart, the rules and guidelines to follow before filing an issue or submitting a PR, and our policy on LLM-assisted contributions. Code conventions are documented in STYLE_GUIDE.md.

Security disclosures

If you discover any security issues, please send an email to [email protected]. The email is automatically CCed to the entire team, and we'll respond promptly. See SECURITY.md for more info.

License

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

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

Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see the original announcement.


Overview (`.changeset/README.md`)

Changesets

Hello and welcome! This folder has been automatically generated by @changesets/cli, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it in our repository

We have a quick list of common questions to get you started engaging with this project in
our documentation


Overview (`interop-test-files/README.md`)

atproto Interop Test Files

This directory contains reusable files for testing interoperability and specification compliance for atproto (AT Protocol).

These files are a copy of https://github.com/bluesky-social/atproto-interop-tests/, which is their canonical source and is shared across atproto implementations. Any change made here must also be contributed upstream to that repository.

The protocol itself is documented at https://atproto.com/specs/atp. If there are conflicts or ambiguity between these test files and the specs, the specs are the authority, and these test files should usually be corrected.

These files are intended to be simple (JSON, text files, etc) and mostly self-documenting.


Overview (`lexicons-overrides/README.md`)

Lexicons Overrides Directory

Any lexicon document placed in this directory will override the corresponding lexicon during resolution (depending on the "resolvers" config in lexicons.json):

json
{
  "version": 2,
  "resolvers": [
    // Canonical lexicons from this repository, always included first
    {
      "type": "directory",
      "path": "../../lexicons",
      "include": ["com.atproto.*", "tools.ozone.*"]
    },

    // Overrides from this directory, always specified after the canonical lexicons
    {
      "type": "directory",
      "path": "../../lexicons-overrides"
    }

    // Other resolvers come here
  ]
}

Because this resolver is listed after the canonical one (which resolves com.atproto.* / tools.ozone.* first), overrides apply to lexicons that would otherwise be resolved from the network or the bsky GitHub repository — not to the canonical namespaces. Valid use cases include:

  • Providing prototypes or experimental versions of lexicons before they are finalized.
  • Overriding a network- or GitHub-resolved lexicon for testing or development purposes.
  • Providing a copy of a lexicon document that can no longer be resolved from its upstream source.
  • Relaxing a published third-party lexicon that existing network records violate, so read paths accept them (see Compatibility overrides).

[!NOTE]

The file path of the override should match the lexicon it is intended to override. For example, to override com.atproto.example, the file should be named com/atproto/example.json and must contain a valid JSON lexicon document with an id field matching the lexicon ID (e.g., "id": "com.atproto.example").

Create JSON Lexicon schemas in this directory to provide custom implementations or modifications for specific lexicons instead of attempting to resolve them from the canonical source (AT Protocol lexicon resolution).

[!NOTE]

Please keep this directory tidy. Do remove any override file that is no longer needed (then run pnpm run lex:update).

Compatibility overrides

A compatibility override is a copy of a published third-party lexicon with the smallest relaxation that lets read paths accept records already on the network. Keep every other constraint identical to the upstream document.

Packages that validate writes (the PDS) must keep validating against the publisher's schema, so they must exclude every compatibility override from their overrides resolver (see the exclude list in packages/pds/lexicons.json). Read-only consumers (the AppView and Ozone) resolve them like any other override.

  • social.grain.photo
    • Upstream: at://did:plc:e7rftrdyz5e2rw4y6ocszew2/com.atproto.lexicon.schema/social.grain.photo (CID bafyreiec24azdo2caoijpefea3x6qfewjvkfu3zcjj3u3lwf3nx3q7nunu).
    • Relaxation: alt is no longer required.
    • Evidence: published Grain photo records omit alt (observed 2026-10-17), so the AppView dropped them from gallery views.
    • Remove when: Grain backfills alt on existing records or publishes the relaxation.

Overview (`lexicons/README.md`)

Canonical Lexicons Directory

This folder contains the canonical lexicons definitions of the com.atproto and tools.ozone namespaces.

Any change made to these lexicons will be published on the AT Protocol once landed on the main branch.

The canonical source for the app.bsky and chat.bsky lexicons is the bluesky-social/bsky GitHub repository.

Other lexicons namespaces used in this repository are either resolved from the AT Protocol network, or hard wired in the lexicons-overrides directory.

Any lexicon document fetched from the network or the bsky GitHub repository is committed (hard wired) into the consuming package for offline builds and CI. Only the canonical com.atproto / tools.ozone schemas, which each package symlinks back to this directory, are left out of version control there.


Overview (`packages/README.md`)

Packages

Every package in this directory is published to npm. Packages under ./internal/ are published under the @atproto-labs/* scope; all others use @atproto/*. Thin runtime wrappers for the services live in ../services/.

Services

  • pds: The Personal Data Server (PDS) — account management, repo storage, blob storage, and OAuth provider. atproto's main server-side implementation.
  • bsky: The app.bsky.* AppView — read-side service for timelines, profiles, feed generators, and view hydration.
  • bsync: Cross-AppView synchronization service (mutes, notifications) over Connect-RPC.
  • ozone: Moderation service implementing the tools.ozone.* API.

Lexicon SDK

The modern, type-safe Lexicon toolchain. Most consumers only need @atproto/lex, which re-exports the core pieces.

  • lex: The umbrella SDK — lex install / lex build CLI, typed XRPC client, and re-exports of the packages below.
  • lex-schema: The runtime schema system that generated code is built on.
  • lex-builder: Generates TypeScript schemas from Lexicon documents.
  • lex-document: Validation of Lexicon documents themselves.
  • lex-installer: Resolves and installs Lexicon documents into a project.
  • lex-resolver: Resolves Lexicon documents from the network.
  • lex-data: Core data-model types and utilities.
  • lex-json: JSON encoding/decoding of Lexicon data.
  • lex-cbor: CBOR (DRISL) encoding/decoding of Lexicon data.
  • lex-client: HTTP (XRPC) client for Lexicon-defined APIs.
  • lex-server: Request router for Lexicon-defined APIs.
  • lex-password-session: Password-based client authentication, for scripts and bots.
  • xrpc-server: Express-based XRPC server. server.add() registers routes from generated lexicon schemas; used by all four services in this repo.

OAuth

Protocol libraries

  • syntax: Identifier and string-format validation: DID, handle, NSID, AT URI, TID, record key, datetime.
  • did: DID parsing, resolution, and verification.
  • identity: Decentralized identity resolution combining DIDs and handles.
  • crypto: Cryptographic keys and signing.
  • repo: The atproto repository implementation (a Merkle Search Tree) and CAR handling.
  • sync: Firehose consumption and repo synchronization.
  • tap: Client for the atproto tap (event stream) interface.
  • ws-client: Long-lived WebSocket client connections.
  • lexicon-resolver: Network resolution of Lexicon documents.

Earlier client and schema stack

Being replaced by the Lexicon SDK above. Still used in parts of this repo; prefer @atproto/lex for new code.

  • api: Client library for atproto and Bluesky.
  • lexicon: The original Lexicon schema language and validation library.
  • xrpc: XRPC client implementation.
  • lex-cli: Codegen tool for the @atproto/api / @atproto/lexicon stack.

Shared internals

Utility packages published under the @atproto-labs/* scope, all located in ./internal/.

A few further shared libraries live at the top level under the @atproto/* scope:

  • common: Code shared between atproto packages (Node-friendly).
  • common-web: The web-platform-friendly subset of common.
  • aws: AWS helpers (S3 blob storage, etc.) for atproto services.

Development tooling

  • dev-env: Boots a full PDS + AppView + bsync + PLC + Ozone constellation in-process, for tests and the make run-dev-env REPL.
  • dev-infra: Docker Compose definitions for the backing services (Postgres, Redis, etc.) used by tests.

Overview (`packages/api/README.md`)

ATP API

This API is a client for ATProtocol servers. It communicates using HTTP. It includes:

  • ✔️ APIs for ATProto and Bluesky.
  • ✔️ Validation and complete typescript types.
  • ✔️ Session management.
  • ✔️ A RichText library.

[!Note]

We recommend using lex, our type-safe SDK with Lexicon code generation, for all new projects.

Getting started

First install the package:

sh
yarn add @atproto/api

Then in your application:

typescript
import { Agent, CredentialSession } from '@atproto/api'

const session = new CredentialSession(new URL('https://bsky.social'))
await session.login(account)
const agent = new Agent(session)

Usage

Session management

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

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

App password based session management

Username / password based authentication can be performed using the Agent
class.

[!CAUTION]

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

typescript
import { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api'

// Configure connection to the server with authentification
const account: AtpAgentLoginOpts = {
  identifier: 'your.bsky.social',
  password: 'xxxx-xxxx-xxxx-xxxx',
}

async function authenticate(account: AtpAgentLoginOpts): Promise<Agent> {
  const session = new CredentialSession(new URL('https://example.com'))
  await session.login(account)
  const agent = new Agent(session)
  return agent
}

;(async () => {
  console.log('Authenticating...')
  const agent = await authenticate(account)
  console.log(`Authenticated as from: ${agent.sessionManager.did}`)
})()

OAuth based session management

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

Every @atproto/oauth-client-* implementation has a different way to obtain an
OAuthSession instance that can be used to instantiate an Agent (from
@atproto/api). Here is an example restoring a previously saved session:

typescript
import { Agent } from '@atproto/api'
import { OAuthClient } from '@atproto/oauth-client'

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

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

// Instantiate the api Agent using an OAuthSession
const agent = new Agent(oauthSession)

API calls

The agent includes methods for many common operations, including:

text
/* Detailed source-code truncated for AI context efficiency. */

Validation and types

The package includes a complete types system which includes validation and type-guards. For example, to validate a post record:

typescript
import { AppBskyFeedPost } from '@atproto/api'

const post = {...}
if (AppBskyFeedPost.isRecord(post)) {
  // typescript now recognizes `post` as a AppBskyFeedPost.Record
  // however -- we still need to validate it
  const res = AppBskyFeedPost.validateRecord(post)
  if (res.success) {
    // a valid record
  } else {
    // something is wrong
    console.log(res.error)
  }
}

Rich text

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

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

typescript
import { RichText } from '@atproto/api'

// creating richtext
const rt = new RichText({
  text: 'Hello @alice.com, check out this link: https://example.com',
})
await rt.detectFacets(agent) // automatically detects mentions and links
const postRecord = {
  $type: 'app.bsky.feed.post',
  text: rt.text,
  facets: rt.facets,
  createdAt: new Date().toISOString(),
}

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

// calculating string lengths
const rt2 = new RichText({ text: 'Hello' })
console.log(rt2.length) // => 5
console.log(rt2.graphemeLength) // => 5
const rt3 = new RichText({ text: '👨‍👩‍👧‍👧' })
console.log(rt3.length) // => 25
console.log(rt3.graphemeLength) // => 1

Moderation

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

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

For more information, see the Moderation Documentation.

text
/* Detailed source-code truncated for AI context efficiency. */

Advanced

Advanced API calls

The methods above are convenience wrappers. It covers most but not all available methods.

The AT Protocol identifies methods and records with reverse-DNS names. You can use them on the agent as well:

typescript
const res1 = await agent.com.atproto.repo.createRecord({
  did: alice.did,
  collection: 'app.bsky.feed.post',
  record: {
    $type: 'app.bsky.feed.post',
    text: 'Hello, world!',
    createdAt: new Date().toISOString(),
  },
})
const res2 = await agent.com.atproto.repo.listRecords({
  repo: alice.did,
  collection: 'app.bsky.feed.post',
})

const res3 = await agent.app.bsky.feed.post.create(
  { repo: alice.did },
  {
    text: 'Hello, world!',
    createdAt: new Date().toISOString(),
  },
)
const res4 = await agent.app.bsky.feed.post.list({ repo: alice.did })

Non-browser configuration

If your environment doesn't have a built-in fetch implementation, you'll need
to provide one. This will typically be done through a polyfill.

Bring your own fetch

If you want to provide your own fetch implementation, you can do so by
instantiating the sessionManager with a custom fetch implementation:

typescript
import { AtpAgent } from '@atproto/api'

const myFetch = (input: RequestInfo | URL, init?: RequestInit) => {
  console.log('requesting', input)
  const response = await globalThis.fetch(input, init)
  console.log('got response', response)
  return response
}

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

License

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

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


Overview (`packages/aws/README.md`)

AWS KMS

A Keypair-compatible wrapper for AWS KMS.


Overview (`packages/bsky/README.md`)

@atproto/bsky: Bluesky AppView Service

TypeScript implementation of the app.bsky Lexicons backing the https://bsky.app microblogging application.


License

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

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


Overview (`packages/bsync/README.md`)

@atproto/bsync: Synchronizing Service for the Bluesky AppView

This is an optional service that may be used to synchronize certain state between otherwise independent AppViews.


License

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

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


Overview (`packages/common-web/README.md`)

@atproto/common-web

Shared TypeScript code for other @atproto/* packages, which is web-friendly (runs in-browser).


License

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

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


Overview (`packages/common/README.md`)

@atproto/common

Shared TypeScript code for other @atproto/* packages. This package is oriented towards writing servers, and is not designed to be browser-compatible.


License

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

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


Overview (`packages/crypto/README.md`)

@atproto/crypto

TypeScript library providing basic cryptographic helpers as needed in atproto.


This package implements the two currently supported cryptographic systems:

  • P-256 elliptic curve: aka "NIST P-256", aka secp256r1 (note the r), aka prime256v1
  • K-256 elliptic curve: aka "NIST K-256", aka secp256k1 (note the k)

The details of cryptography in atproto are described in the specification. This includes string encodings, validity of "low-S" signatures, byte representation "compression", hashing, and more.

Usage

typescript
import { verifySignature, Secp256k1Keypair, P256Keypair } from '@atproto/crypto'

// generate a new random K-256 private key
const keypair = await Secp256k1Keypair.create({ exportable: true })

// sign binary data, resulting signature bytes.
// SHA-256 hash of data is what actually gets signed.
// signature output is often base64-encoded.
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
const sig = await keypair.sign(data)

// serialize the public key as a did:key string, which includes key type metadata
const pubDidKey = keypair.did()
console.log(pubDidKey)

// output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38'

// verify signature using public key
const ok = verifySignature(pubDidKey, data, sig)
if (!ok) {
  throw new Error('Uh oh, something is fishy')
} else {
  console.log('Success')
}

License

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

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


Overview (`packages/dev-env/README.md`)

@atproto/dev-env: Local Developer Environment

A command-line application for developers to construct and manage development environments.


REPL API

The following methods are available in the REPL.

`status()`

List the currently active servers.

`startPds(port?: number)`

Create a new PDS instance. Data is stored in memory.

`stop(port: number)`

Stop the server at the given port.

`mkuser(handle: string, pdsPort?: number)`

Create a new user.

`user(handle: string): ServiceClient`

Get the ServiceClient for the given user.

License

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

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


Overview (`packages/dev-infra/README.md`)

dev-infra

Helpers for working with postgres and redis locally. Previously known as pg.

Usage

`with-test-db.sh`

This 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.

The 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 that are recognized by libpq (i.e. used by the psql client) are also set.

Example

text
$ ./with-test-db.sh psql -c 'select 1;'
[+] Running 1/1
 ⠿ Container pg-db_test-1  Healthy                                                           1.8s

 ?column?
----------
        1
(1 row)


[+] Running 1/1
 ⠿ Container pg-db_test-1  Stopped                                                           0.1s
Going to remove pg-db_test-1
[+] Running 1/0
 ⠿ Container pg-db_test-1  Removed

`with-redis-and-test-db.sh`

This 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.

The 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.

`docker-compose.yaml`

The 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:

  • Username: pg
  • Password: password

However, each service uses a different port, documented below, to avoid conflicts.

`db_test` service for single use

The single-use db_test service does not have any persistent storage. When the container is removed, data in the database disappears with it.

This service runs on port 5433.

text
$ docker compose up db_test   # start container
$ docker compose stop db_test # stop container
$ docker compose rm db_test   # remove container

`db` service for persistent use

The 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.

This service runs on port 5432.

text
$ docker compose up db -d    # start container
$ docker compose stop db     # stop container
$ docker compose rm db       # remove container
$ docker volume rm pg_atp_db # remove volume

`redis_test` service for single use

The single-use redis_test service does not have any persistent storage. When the container is removed, the data in redis disappears with it.

This service runs on port 6380.

`redis` service for persistent use

The 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.

This service runs on port 6379.


Overview (`packages/identity/README.md`)

@atproto/identity

TypeScript library for decentralized identities in atproto using DIDs and handles


Example

Resolving a Handle and verifying against DID document:

typescript
const didres = new DidResolver({})
const hdlres = new HandleResolver({})

const handle = 'atproto.com'
const did = await hdlres.resolve(handle)

if (did == undefined) {
  throw new Error('expected handle to resolve')
}
console.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz

const doc = await didres.resolve(did)
console.log(doc)

// additional resolutions of same DID will be cached for some time, unless forceRefresh flag is used
const doc2 = await didres.resolve(did, true)

// helper methods use the same cache
const data = await didres.resolveAtprotoData(did)

if (data.handle != handle) {
  throw new Error('invalid handle (did not match DID document)')
}

SSRF protection

HTTP identity resolution — the handle /.well-known/atproto-did endpoint,
did:plc, and did:web — is routed through an SSRF-protected fetch by
default. Private IPs, IP-literal hosts, http: URLs, custom ports, and a
handful of forbidden domain names are refused.

To resolve identities against localhost or another private network — a test
suite or a local dev stack — pass your own fetch. A supplied fetch is
used as-is and is never re-wrapped:

typescript
const didres = new IdResolver({ plcUrl, fetch: globalThis.fetch })

License

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

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


Overview (`packages/lex-cli/README.md`)

Lexicon CLI Tool

Command-line tool to generate Lexicon schemas and APIs.

Usage

text
Usage: lex [options] [command]

Lexicon CLI

Options:
  -V, --version                     output the version number
  -h, --help                        display help for command

Commands:
  new [options] <nsid> [outfile]    Create a new schema json file
  gen-md <schemas...>               Generate markdown documentation
  gen-ts-obj <schemas...>           Generate a TS file that exports an array of schemas
  gen-api <outdir> <schemas...>     Generate a TS client API
  gen-server <outdir> <schemas...>  Generate a TS server API
  help [command]                    display help for command

Example 1: Generate an api

text
$ lex gen-api ./api/src ./schemas/com/service/*.json ./schemas/com/another/*.json

Example 2: Generate a server

text
$ lex gen-server ./server/src/xrpc ./schemas/com/service/*.json ./schemas/com/another/*.json

License

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

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


Overview (`packages/lexicon-resolver/README.md`)

@atproto/lexicon-resolver

ATProto Lexicon resolution


Usage

This package may be used to determine the DID authority for a Lexicon based on its NSID, and to resolve a Lexicon from its NSID based on Lexicon Resolution from the network. Resolutions always verify the inclusion proof for the Lexicon schema document published to the ATProto network.

ts
import {
  resolveLexicon,
  resolveLexiconDidAuthority,
} from '@atproto/lexicon-resolver'

// Which DID is the authority over this Lexicon?
const didAuthority = await resolveLexiconDidAuthority('app.bsky.feed.post')
// Resolve the Lexicon document with resolution details
const resolved = await resolveLexicon('app.bsky.feed.post')
/**
 * {
 *   commit: {
 *     did: 'did:plc:4v4y5r3lwsbtmsxhile2ljac',
 *     rev: '3lnlpukgipj2c',
 *     sig: Uint8Array(64),
 *     ...
 *   },
 *   uri: AtUri(at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.post),
 *   cid: CID(bafyreidgbehqwweghrrddfu6jgj7lyr6fwhzgazhirnszdb5lvr7iynkiy),
 *   nsid: NSID('app.bsky.feed.post'),
 *   lexicon: {
 *     '$type': 'com.atproto.lexicon.schema',
 *     lexicon: 1
 *     id: 'app.bsky.feed.post',
 *     defs: { main: [Object], ... },
 *   }
 * }
 */

With identity caching

Identity data is used in order to fetch and verify record contents. The @atproto/identity package can be used to offer more control over caching and other behaviors of identity lookups.

ts
import { IdResolver, MemoryCache } from '@atproto/identity'
import { buildLexiconResolver } from '@atproto/lexicon-resolver'

const resolveLexicon = buildLexiconResolver({
  idResolver: new IdResolver({
    didCache: new MemoryCache(),
  }),
})

const resolved = await resolveLexicon('app.bsky.feed.post')

With DID authority override

You may specify a specific DID authority you'd like to use to perform a Lexicon resolution, overriding ATProto's DNS-based authority over Lexicons. This is described in some more detail in Authority and Control.

ts
import { resolveLexicon } from '@atproto/lexicon-resolver'

const resolved = await resolveLexicon('app.bsky.feed.post', {
  didAuthority: 'did:plc:...',
})

License

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

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


Overview (`packages/lexicon/README.md`)

@atproto/lexicon: schema validation library

TypeScript implementation of the Lexicon data and API schema description language, which is part of atproto.


Usage

typescript
import { Lexicons } from '@atproto/lexicon'

// create your lexicons collection
const lex = new Lexicons()

// add lexicon documents
lex.add({
  lex: 1,
  id: 'com.example.post',
  defs: {
    // ...
  }
})

// validate
lex.assertValidRecord('com.example.record', {$type: 'com.example.record', ...})
lex.assertValidXrpcParams('com.example.query', {...})
lex.assertValidXrpcInput('com.example.procedure', {...})
lex.assertValidXrpcOutput('com.example.query', {...})

License

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

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


Overview (`packages/ozone/README.md`)

@atproto/ozone: Bluesky Moderation Service

Backend service for moderating the Bluesky network.


DID cache

The API uses a bounded in-memory DID document cache. Configure its limits with OZONE_DID_CACHE_MAX_ENTRIES (default 10,000), OZONE_DID_CACHE_MAX_BYTES (default 33,554,432 bytes for serialized documents and DID keys), and OZONE_DID_CACHE_MAX_DOC_BYTES (default 65,536 bytes per serialized document). Set any limit to 0 to disable retention. Documents exceeding a limit can still be resolved but are not cached. Restart the API after changing these settings.

Report tags

Reports automatically add a reason tag such as report:spam to their subject by default. Set OZONE_REPORT_TAGS_DISABLED=true and restart the API to disable these automatic report tags. Unset or false keeps them enabled.

The setting applies to reports submitted through both com.atproto.moderation.createReport and tools.ozone.moderation.emitEvent.

Language and embed tags, manually applied tags, and existing report tags are unaffected.

Wildcard label queries

Wildcard patterns in com.atproto.label.queryLabels are disabled by default, including * and requests mixing wildcard and exact patterns. They return HTTP 400 (InvalidRequest) with the message "Support for wildcard label queries is temporarily disabled" before accessing the database. Exact URI queries remain available.

Set OZONE_LABEL_QUERY_WILDCARDS_ENABLED=true and restart the API to restore wildcard support. Unset or false disables it.

Historical label replay

com.atproto.label.subscribeLabels connections with a cursor share a historical replay budget on each API process. OZONE_LABEL_STREAM_BACKFILL_RATE sets the positive integer number of labels per second; the default is 2000. Pages contain at most 500 labels, so brief page-sized bursts are possible. Restart the API after changing the setting.

This bounds bulk replay work while preserving history and cursor ordering. Large backfills take longer, and multiple replaying clients share the budget. Live subscriptions and sequencer polling do not wait for this budget; cutover queries remain bounded by the existing stream buffer limit.

Subscription start and close logs include a connection ID, cursor, proxy socket address/port, User-Agent and forwarded address chain. Close logs add the last sequence, yielded event count and duration. Forwarded headers and User-Agent are attribution hints, not authenticated caller identities; cookies and authorization headers are excluded.

Moderation query cancellation

queryEvents and queryStatuses cancel their listing SQL when the HTTP client disconnects, using Kysely's cancel query abort strategy. Kysely uses a separate PostgreSQL connection for cancellation and holds the listing connection until SQL and cancellation finish. This adds no query deadline.

Moderation inbox standing

Set NEXT_PUBLIC_STRIKE_SUSPENSION_CONFIG to the same value used by Ozone UI, for example 4:72,8:168,12:336,16:Infinity. Each entry maps a strike count to suspension hours; Infinity represents a permanent suspension. The backend retains the full map and uses the second and third thresholds, sorted by strike count, for warning and atRisk. The first tier does not change standing. With no configuration, standing derives from the account's enforcement and restrictions; strike thresholds are disabled. Invalid entries fail configuration at startup.

Moderation inbox start date

Set OZONE_INBOX_START_AT on both the API and daemon to an ISO 8601 timestamp with a timezone, for example 2026-10-15T00:00:00.000Z. Configuration normalizes it to UTC milliseconds and rejects invalid values at startup. Unset exposes all history. A future date keeps history empty until eligible activity occurs; changing the value requires restarting the services.

Reports are visible only when their createdAt is at or after the date, even if an older report is later resolved or reopened. The same bound applies to report detail and resolution actions. Subjects require a public action created at or after the date; action counts, first-action dates, latest actions, detail history and report summaries exclude earlier history. Older appeal history is hidden, while eligibility still respects earlier appeals. Inbox appeals cannot target hidden actions or subjects. Moderator previews use the same cutoff.

Account standing and current enforcement remain live: active restrictions, strikes and labels can predate the inbox start. Preferences and seen watermarks do not expose history; they retain their existing behavior. The cutoff is not a read watermark, so an item created exactly at the start date is initially unread.

Notification producers skip activity before the date and skip transitions on reports or appeal subjects that would be hidden. Lists and unread counts independently enforce the date and target visibility, including existing stored notifications. API and daemon must use the same value. Source reports/actions continue to be recorded normally. Moving the date earlier can reveal more source history, but skipped notifications are not backfilled. Complete the writer rollout before the chosen start date to avoid a notification gap.

Report-first pagination requires the reporterDid backfill and the post-retirement catch-up sweep to finish before deploying this reader. Pre-create the reporter/time indexes and idx_moderation_event_inbox_public_created concurrently in production before the inbox migration. The latter indexes (subjectDid, createdAt DESC, id DESC) so subject history can seek the configured date.

Strike expiry

Clients supply strike amounts and expiry timestamps from severity settings.
The backend stores them and excludes expired strikes from active totals.

Legacy events without expiry require a separate repair. Changing expiry does
not undo takedowns, suspensions, or lifetime strike totals.

License

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

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


Overview (`packages/pds/README.md`)

@atproto/pds: Personal Data Server (PDS)

TypeScript reference implementation of an atproto PDS.


If you are interested in self-hosting a PDS, you probably want this repository instead, which has a thin service wrapper, documentation, a Dockerfile, etc: https://github.com/bluesky-social/pds

License

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

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


Overview (`packages/repo/README.md`)

@atproto/repo: Repository and MST

TypeScript library for atproto repositories, and in particular the Merkle Search Tree (MST) data structure.


Repositories in atproto are signed key/value stores containing CBOR-encoded data records. The structure and implementation details are described in the specification. This includes MST node format, serialization, structural constraints, and more.

License

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

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


Overview (`packages/sync/README.md`)

@atproto/sync: atproto sync tools

TypeScript library for syncing data from the atproto network. Currently only supports firehose (relay) subscriptions


Usage

The firehose class will spin up a websocket connection to com.atproto.sync.subscribeRepos on a given repo host (by default the Relay run by Bluesky).

Each event will be parsed, authenticated, and then passed on to the supplied handleEvt which can handle indexing.

On Commit events, the firehose will verify signatures and repo proofs to ensure that the event is authentic. This can be disabled with the unauthenticatedCommits flag. Similarly on Identity events, the firehose will fetch the latest DID document for the repo and do bidirectional verification on the associated handle. This can be disabled with the unauthenticatedHandles flag.

Events of a certain type can be excluded using the excludeIdentity/excludeAccount/excludeCommit flags. And repo writes can be filtered down to specific collections using filterCollections. By default, all events are parsed and passed through to the handler. Note that this filtered currently happens client-side, though it is likely we will introduce server-side methods for doing so in the future.

Non-fatal errors that are encountered will be passed to the required onError handler. In most cases, these can just be logged.

When using the firehose class, events are processed serially. Each event must be finished being handled before the next one is parsed and authenticated.

ts
import { Firehose } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'

const idResolver = new IdResolver()
const firehose = new Firehose({
  idResolver,
  service: 'wss://bsky.network',
  handleEvt: async (evt) => {
    if (evt.event === 'identity') {
      // ...
    } else if (evt.event === 'account') {
      // ...
    } else if (evt.event === 'create') {
      // ...
    } else if (evt.event === 'update') {
      // ...
    } else if (evt.event === 'delete') {
      // ...
    }
  },
  onError: (err) => {
    console.error(err)
  },
  filterCollections: ['com.myexample.app'],
})
firehose.start()

// on service shutdown
await firehose.destroy()

For more robust indexing pipelines, it's recommended to use the supplied MemoryRunner class. This provides an in-memory partitioned queue. As events from a given repo must be processed in order, this allows events to be processed concurrently while still processing events from any given repo serially.

The MemoryRunner also tracks an internal cursor based on the last finished consecutive work. This ensures that no events are dropped, although it does mean that some events may occassionally be replayed (if the websocket drops and reconnects) and therefore it's recommended that any indexing logic is idempotent. An optional setCursor parameter may be supplied to the MemoryRunner which can be used to persistently store the most recently processed cursor.

ts
import { Firehose, MemoryRunner } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'

const idResolver = new IdResolver()
const runner = new MemoryRunner({
  setCursor: (cursor) => {
    // persist cursor
  },
})
const firehose = new Firehose({
  idResolver,
  runner,
  service: 'wss://bsky.network',
  handleEvt: async (evt) => {
    // ...
  },
  onError: (err) => {
    console.error(err)
  },
})
firehose.start()

// on service shutdown
await firehose.destroy()
await runner.destroy()

License

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

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


Overview (`packages/syntax/README.md`)

@atproto/syntax: validation helpers for identifier strings

Validation logic for atproto identifiers - DIDs, Handles, NSIDs, and AT URIs.


Usage

Handles

Syntax specification: https://atproto.com/specs/handle

typescript
import { isValidHandle, ensureValidHandle, isValidDid } from '@atproto/syntax'

isValidHandle('alice.test') // returns true
ensureValidHandle('alice.test') // returns void

isValidHandle('al!ce.test') // returns false
ensureValidHandle('al!ce.test') // throws

ensureValidDid('did:method:val') // returns void
ensureValidDid(':did:method:val') // throws

NameSpaced IDs (NSID)

Syntax specification: https://atproto.com/specs/nsid

typescript
import { NSID } from '@atproto/syntax'

const id1 = NSID.parse('com.example.foo')
id1.authority // => 'example.com'
id1.name // => 'foo'
id1.toString() // => 'com.example.foo'

const id2 = NSID.create('example.com', 'foo')
id2.authority // => 'example.com'
id2.name // => 'foo'
id2.toString() // => 'com.example.foo'

const id3 = NSID.create('example.com', 'someRecord')
id3.authority // => 'example.com'
id3.name // => 'someRecord'
id3.toString() // => 'com.example.someRecord'

NSID.isValid('com.example.foo') // => true
NSID.isValid('com.example.someRecord') // => true
NSID.isValid('example.com/foo') // => false
NSID.isValid('foo') // => false

AT URI

Syntax specification: https://atproto.com/specs/at-uri-scheme

typescript
import { AtUri } from '@atproto/syntax'

const uri = new AtUri('at://bob.com/com.example.post/1234')
uri.protocol // => 'at:'
uri.origin // => 'at://bob.com'
uri.hostname // => 'bob.com'
uri.collection // => 'com.example.post'
uri.rkey // => '1234'

License

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

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


Overview (`packages/tap/README.md`)

@atproto/tap

TypeScript client library for Tap, a sync utility for the AT Protocol network.

Tap handles firehose connections, cryptographic verification, backfill, and filtering. This client library lets you connect to a Tap instance and receive simple JSON events for the repos you care about.


Quick Start

bash
npm install @atproto/tap
ts
import { Tap, SimpleIndexer } from '@atproto/tap'

const tap = new Tap('http://localhost:2480', { adminPassword: 'secret' })

const indexer = new SimpleIndexer()

indexer.identity(async (evt) => {
  console.log(`${evt.did} updated identity: ${evt.handle} (${evt.status})`)
})

indexer.record(async (evt) => {
  const uri = `at://${evt.did}/${evt.collection}/${evt.rkey}`
  if (evt.action === 'create' || evt.action === 'update') {
    console.log(`${evt.action}: ${uri}`)
  } else {
    console.log(`deleted: ${uri}`)
  }
})

indexer.error((err) => console.error(err))

const channel = tap.channel(indexer)
channel.start()

await tap.addRepos(['did:plc:ewvi7nxzyoun6zhxrhs64oiz'])

// On shutdown
await channel.destroy()

Running Tap

See the Tap README for details getting Tap up and running. Your app can communicate with it either locally or over the internet.

This library is intended to be used with Tap running in the default mode of "WebScoket with acks". In this mode, Tap provides:

  • At-least-once delivery: Events may be redelivered if the connection drops before an ack is received
  • Per-repo ordering: Events for the same repo are delivered in order
  • Backfill: When you add a repo, historical events are delivered before live events

API

`Tap`

The main client for interacting with a Tap server.

ts
const tap = new Tap(url: string, config?: TapConfig)

Config options:

  • adminPassword?: string - Password for Basic auth (required if Tap server has auth enabled)

Methods:

  • channel(handler: TapHandler, opts?: TapWebsocketOptions): TapChannel - Create a WebSocket channel to receive events
  • addRepos(dids: string[]): Promise<void> - Add repos to track (triggers backfill)
  • removeRepos(dids: string[]): Promise<void> - Stop tracking repos
  • resolveDid(did: string): Promise<DidDocument | null> - Resolve a DID to its DID document
  • getRepoInfo(did: string): Promise<RepoInfo> - Get info about a tracked repo

`TapChannel`

WebSocket connection for receiving events. Created via tap.channel().

ts
const channel = tap.channel(handler, opts?)

Methods:

  • start(): Promise<void> - Start receiving events. Returns a promise that resolves when the connection is destroyed or errors.
  • destroy(): Promise<void> - Close the connection

The channel automatically handles reconnection and keepalive. Events are automatically acknowledged after your handler completes successfully.

`SimpleIndexer`

A convenience class for handling events by type. Passed into tap.channel() when opening a channel with Tap.

ts
const indexer = new SimpleIndexer()

indexer.identity(async (evt: IdentityEvent) => { ... })
indexer.record(async (evt: RecordEvent) => { ... })
indexer.error((err: Error) => { ... })

If no error handler is registered, errors will throw as unhandled exceptions.

`LexIndexer`

A typed indexer that uses @atproto/lex schemas for type-safe event handling. Register handlers for specific record types and actions:

ts
import { LexIndexer } from '@atproto/tap'
import * as com from './lexicons/com'

const indexer = new LexIndexer()

// Handle creates for a specific record type
indexer.create(com.example.post, async (evt) => {
  // evt.record is fully typed as com.example.post.Main
  console.log(`New post: ${evt.record.text}`)
})

// Handle updates
indexer.update(com.example.post, async (evt) => {
  console.log(`Updated post: ${evt.record.text}`)
})

// Handle deletes (no record on delete events)
indexer.delete(com.example.post, async (evt) => {
  console.log(`Deleted: at://${evt.did}/${evt.collection}/${evt.rkey}`)
})

// Handle both creates and updates with put()
indexer.put(com.example.like, async (evt) => {
  console.log(`Like ${evt.action}: ${evt.record.subject.uri}`)
})

// Fallback for unhandled record types/actions
indexer.other(async (evt) => {
  console.log(`Unhandled: ${evt.action}, ${evt.collection}`)
})

// Identity and error handlers
indexer.identity(async (evt) => { ... })
indexer.error((err) => { ... })

const channel = tap.channel(indexer)

Records are validated against their schemas before handlers are called. If validation fails, an error is thrown which will be picked up through the error handler..

Duplicate handler registration throws an error, including conflicts between put() and create()/update() for the same schema.

`TapHandler`

You can create your own custom handler by creating a class that implements the TapHandler interface:

ts
interface TapHandler {
  onEvent: (evt: TapEvent, opts: HandlerOpts) => void | Promise<void>
  onError: (err: Error) => void
}

interface HandlerOpts {
  signal: AbortSignal
  ack: () => Promise<void>
}

When implementing a custom handler, be sure to call ack() when you're done processing the event.

Event Types

`RecordEvent`

ts
type RecordEvent = {
  id: number
  type: 'record'
  action: 'create' | 'update' | 'delete'
  did: string
  rev: string
  collection: string
  rkey: string
  record?: Record<string, unknown> // present for create/update
  cid?: string // present for create/update
  live: boolean // true if from firehose, false if from backfill
}

`IdentityEvent`

ts
type IdentityEvent = {
  id: number
  type: 'identity'
  did: string
  handle: string
  isActive: boolean
  status: 'active' | 'takendown' | 'suspended' | 'deactivated' | 'deleted'
}

Webhook Mode

If your Tap server is configured for webhook delivery, you can use parseTapEvent to validate incoming webhook payloads:

ts
import express from 'express'
import { parseTapEvent, assureAdminAuth } from '@atproto/tap'

const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD

const app = express()
app.use(express.json())

app.post('/webhook', async (req, res) => {
  try {
    assureAdminAuth(ADMIN_PASSWORD, req.headers.authorization)
  } catch {
    return res.status(401).json({ error: 'Unauthorized' })
  }

  try {
    const evt = parseTapEvent(req.body)
    // handle event...
    res.sendStatus(200)
  } catch (err) {
    console.error('Failed to process event:', err)
    res.status(500).json({ error: 'Failed to process event' })
  }
})

Utilities

Auth helpers

ts
import {
  formatAdminAuthHeader,
  parseAdminAuthHeader,
  assureAdminAuth,
} from '@atproto/tap'

// Format a password into a Basic auth header value
const header = formatAdminAuthHeader('secret')
// => 'Basic YWRtaW46c2VjcmV0'

// Parse an auth header to extract the password (throws if invalid)
const password = parseAdminAuthHeader(header)

// Verify auth header matches expected password (timing-safe, throws if invalid)
assureAdminAuth('secret', req.headers.authorization)

Event parsing

ts
import { parseTapEvent } from '@atproto/tap'

const evt = parseTapEvent(jsonData) // validates and returns typed TapEvent

License

MIT


Overview (`packages/ws-client/README.md`)

@atproto/ws-client: WebSocket Client Library

A WebSocket client hardened for long-lived streams


Install

sh
npm install @atproto/ws-client

Overview

The package provides a WebSocket client websocket(url, options?) as an async iterable. It connects, reads messages, reconnects on failure, and yields one continuous stream.

Beyond reading, it covers the concerns a long-lived stream runs into in practice: liveness (a heartbeat and an idle timeout to detect dead connections), flow control (real socket backpressure on Node.js, plus a hard cap on buffered bytes), reconnect classification by close code and error type, and lifecycle hooks. It works on Node.js and in the browser.

ts
import { websocket } from '@atproto/ws-client'

for await (const message of websocket('wss://example.com/feed')) {
  console.log(message)
}

The loop runs across reconnects: if the connection drops and a retry succeeds, iteration continues, and the consumer never sees the gap as a separate stream. A message is a string or Uint8Array depending on the frame's wire type, unless narrowed by dataMode (see below).

`dataMode`

dataMode: 'auto' | 'text' | 'binary' (default 'auto') both types what's yielded and is enforced at runtime:

ts
// message: string
for await (const message of websocket(url, { dataMode: 'text' })) { ... }

// message: Uint8Array
for await (const message of websocket(url, { dataMode: 'binary' })) { ... }

If the server sends a frame of the wrong type under 'text' or 'binary', the stream ends with a DataModeError (non-retryable — a protocol mismatch will recur) rather than silently coercing the data.

Lifecycle

A stream ends in one of two ways: you break (or return/throw) out of the loop, or you abort a signal passed in options.

ts
const controller = new AbortController()
const gen = websocket(url, { signal: controller.signal })

// later, from elsewhere:
controller.abort()

Stopping always closes the socket politely — it's a request to stop, not a failure — and the abort reason is how you choose the close code:

how you stop what the peer sees
break/throw in the loop close 1000
controller.abort() (no argument) close 1000
controller.abort(new CloseError(…)) close with that error's code
controller.abort(anythingElse) close 1000

The last row matters for shutdown paths: controller.abort(new Error('SIGTERM')) is a deliberate stop, so the peer still gets a clean goodbye. The reason isn't lost — it's what the iterator rejects with. break and throw are indistinguishable to a generator, so they share the plain clean close.

In the browser, a CloseError's code carries through only if the platform permits it. The WHATWG API accepts 1000 and the private-use range 3000–4999, and throws on anything else — the rest of the RFC 6455 codes are reserved for the protocol itself to send, even though an endpoint may legitimately receive them. A code outside that set closes with 1000 instead, so pick from 3000–4999 for application-specific codes that need to reach a browser peer. Node.js has no such restriction and sends any code as given.

A non-reconnectable 1000 close — the server says it's done and the reconnect policy declines to retry — ends the stream normally: the loop just finishes, same as break. Every other end rejects the iterator, including a fatal protocol close such as 1002, a network failure, or an aborted signal. Wrap the loop in try/catch if you need to distinguish "the peer said goodbye" from "something went wrong."

ts
try {
  for await (const message of websocket(url)) {
    handle(message)
  }
  // stream ended cleanly
} catch (err) {
  // stream ended abnormally
}

Reconnects

By default a dropped connection is retried with jittered exponential backoff, capped by maxReconnectSeconds (default 64), and the backoff resets to its base after each connection that opens.

  • url may be a function: () => string | URL | Promise<string | URL>. It's re-invoked on every attempt, including reconnects — the idiom for resuming a stream at a cursor (see the firehose example below).
  • shouldReconnect controls whether a failure is retried:
    • true (default) — the built-in policy: each typed error self-classifies, and close codes are classified per RFC 6455.
    • false — never reconnect; the first end is terminal.
    • (error, attempt) => boolean — replaces the default classification.
ts
websocket(url, {
  shouldReconnect: (error, attempt) => attempt < 5,
})

Hooks observe the lifecycle at two levels. onOpen() and onClose(detail) bookend the stream, once each; onConnect(sender) and onDisconnect() bookend each connection, as many times as it takes.

hook when
onOpen() the stream is live, once, just before the first onConnect
onConnect(sender) a connection is up — including the first
onDisconnect() that connection ended
onReconnect(error, { attempt }) a connection failed and a retry is coming
onError(error) a connection failed and the stream is giving up
onClose(detail) the stream ended, once, after the local socket has closed

onReconnect and onError are exclusive, and the split matters: an onReconnect error is swallowed by the retry, so that hook is the only place transient trouble surfaces. An onError error is also what the iterator rejects with, so you can handle it there or in a catch around the loop.

onConnect and onDisconnect pair exactly, and a dial that never connected produces neither. So a stream stuck retrying reports one onDisconnect for the connection it lost and an onReconnect per failed attempt.

onDisconnect, not onReconnect, is where to stop using a sender: the loop only advances when the consumer pulls, so onReconnect can arrive well after the socket died.

onClose fires only once the local socket is closed, so it's safe to treat as "teardown is done" and release whatever the stream depended on. The end of the for await carries the same guarantee: iteration doesn't settle until the socket is down, so awaiting the loop is enough — you don't need the hook to sequence a shutdown.

A polite close waits for the peer to answer, so on Node.js that wait is capped at one second (after which the socket is destroyed and reported as an abnormal 1006) rather than the 30 seconds ws allows by default.

wasClean: true means the closing handshake completed — both sides exchanged close frames — not that the close was a 1000. A 1002 protocol close is reported clean, and still rejects the iterator; the reconnect policy keys on the close code, never on this flag.

Liveness

Two independent mechanisms detect a dead connection that hasn't told you so:

  • Heartbeat (Node.js only): heartbeat: { intervalMs } pings each interval and accepts any inbound frame, not just a pong, as evidence of life. A missed round becomes a HeartbeatTimeoutError and a reconnect. The browser's WebSocket API has no ping/pong, so this option is ignored there.
  • Idle timeout (both platforms): idleTimeoutMs ends the connection with an IdleTimeoutError if no message arrives within the window. This is the browser's only dead-connection detector, so set it there if you need one.

Only the Node case is on by default: heartbeat defaults to a 10s interval (pass heartbeat: false to disable, or { intervalMs } to change it), while idleTimeoutMs is off unless you set it.

A connection that fails discards whatever it had buffered but undelivered. A liveness timeout, a byte-cap overflow, or a transport error drops those messages rather than yielding data from a connection already known to be broken; a clean close still drains what arrived before it. For cursor-based streams that's harmless, since a reconnect resumes from the cursor. If your stream isn't resumable, keep highWaterMark low enough that little is ever in flight.

Flow control

  • Node.js: real socket backpressure. Past highWaterMark (default 1 MiB of buffered, unread bytes) the socket is paused until the consumer catches up.
  • Both platforms: maxBufferedBytes is a hard cap. Exceeding it fails the connection with a BufferOverflowError (non-retryable) rather than growing the buffer without bound. It's the only backstop in the browser, since the WHATWG API gives no way to pause a socket.

Both thresholds count received, unread bytes — there is only one buffer in this library, and nothing bounds the write side. They count binary frames exactly and over-estimate text: a string is measured as UTF-16 code units × 2, which avoids an encode per message but counts mostly-ASCII text at roughly twice its wire size. Both are safety valves, so pausing or failing early is the safer direction to err — just don't read them as exact wire bytes when sizing them for a text stream.

Overflow is fatal by default, on the reasoning that a consumer which fell behind once will fall behind again. If your stream is resumable — a cursor-based feed, say — you can opt into retrying it, which is worth considering in the browser where there's no backpressure to lean on:

ts
import {
  BufferOverflowError,
  defaultShouldReconnect,
  websocket,
} from '@atproto/ws-client'

websocket(url, {
  maxBufferedBytes: 10 * 1024 * 1024,
  shouldReconnect: (error) =>
    error instanceof BufferOverflowError || defaultShouldReconnect(error),
})

Whatever was buffered is discarded, so only reconnect on overflow if you can resume from where you left off.

Compression

permessage-deflate is offered by default on both platforms. There's no option to configure or disable it.

Sending

websocket() yields messages; sending goes through the sender handed to onConnect. A sender belongs to one connection and rejects once that connection ends, so always use the most recent one rather than retaining an older:

ts
import { type Sender, websocket } from '@atproto/ws-client'

let sender: Sender<'text'> | undefined

const stream = websocket(url, {
  dataMode: 'text',
  onConnect: (s) => {
    sender = s
  },
  onDisconnect: () => {
    sender = undefined
  },
})

for await (const message of stream) {
  const event = JSON.parse(message)
  await handle(event)
  await sender?.send(JSON.stringify({ type: 'ack', id: event.id }))
}

send() resolves on hand-off and not on delivery: flushed on Node.js or accepted by the browser's WebSocket.

If you need at-least-once delivery, handle that within the application. If a message needs to be sent when there isn't an active sender, it can be queued then later flushed from onConnect.

Headers (Node.js only)

ts
websocket(url, { headers: { Authorization: `Bearer ${token}` } })

headers applies to the connection's upgrade request. It's Node.js-only: the WHATWG API used in the browser has no way to set request headers, so passing headers there throws at construction rather than silently dropping what is usually an auth credential. BrowserWebSocketOptions omits the field entirely, so it's also a compile-time error if you import the browser-specific option type.

Errors

Every failure is a WebSocketClientError, and each subclass classifies itself via shouldRetry() — which is what the default reconnect policy consults:

Error Meaning Retryable by default
CloseError The connection closed with a given code/reason/cleanliness Classified by close code (RFC 6455)
SocketError A transport-level failure (e.g. connection refused) Yes
HeartbeatTimeoutError No frame observed within the heartbeat window (Node.js) Yes
IdleTimeoutError No message arrived within idleTimeoutMs Yes
BufferOverflowError Buffered bytes exceeded maxBufferedBytes No
DataModeError A frame's type didn't match dataMode No

A bare WebSocketClientError marks misuse of the API itself, like sending on a closed connection, and is never retryable.

Examples

Resuming a firehose from a cursor

A function-valued url is re-invoked on every attempt, including reconnects. Read the latest cursor at call time so a reconnect resumes where the last connection left off instead of restarting the stream:

ts
import { websocket } from '@atproto/ws-client'

let cursor: string | undefined

const stream = websocket(() => {
  const url = new URL('wss://example.com/subscribe')
  if (cursor) url.searchParams.set('cursor', cursor)
  return url
})

for await (const message of stream) {
  cursor = extractCursor(message)
  handle(message)
}

A browser client with an idle timeout

The browser has no heartbeat mechanism, so idleTimeoutMs is its only way to detect a connection that has silently gone dead:

ts
import { websocket } from '@atproto/ws-client'

try {
  for await (const message of websocket('wss://example.com/feed', {
    idleTimeoutMs: 30_000,
  })) {
    render(message)
  }
} catch (err) {
  showDisconnected(err)
}

A note for library authors: keep this package external

This package selects its transport (Node vs. browser) via a conditional imports entry (#transport) that the runtime resolves at import time. If you pre-bundle it into a library's output, that resolution collapses to whichever transport your bundler's target picked, and the result won't adapt to the environment it actually runs in. Mark @atproto/ws-client as external so each consumer's own runtime resolves the transport.

License

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

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


Overview (`packages/xrpc-server/README.md`)

@atproto/xrpc-server: atproto HTTP API server library

TypeScript library for implementing atproto HTTP API services, with Lexicon schema validation.


Usage

typescript
import { LexiconDoc } from '@atproto/lexicon'
import * as xrpc from '@atproto/xrpc-server'
import express from 'express'

const lexicons: LexiconDoc[] = [
  {
    lexicon: 1,
    id: 'io.example.ping',
    defs: {
      main: {
        type: 'query',
        parameters: {
          type: 'params',
          properties: { message: { type: 'string' } },
        },
        output: {
          encoding: 'application/json',
        },
      },
    },
  },
]

// create xrpc server
const server = xrpc.createServer(lexicons)

function ping(ctx: {
  auth: xrpc.HandlerAuth | undefined
  params: xrpc.Params
  input: xrpc.HandlerInput | undefined
  req: express.Request
  res: express.Response
}) {
  return { encoding: 'application/json', body: { message: ctx.params.message } }
}

server.method('io.example.ping', ping)

// mount in express
const app = express()
app.use(server.router)
app.listen(8080)

License

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

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


Overview (`packages/xrpc/README.md`)

@atproto/xrpc: atproto HTTP API Client

TypeScript client library for talking to atproto services, with Lexicon schema validation.


[!NOTE]

We recommend using lex, our type-safe SDK with Lexicon code generation, for all new projects.

Usage

typescript
import { LexiconDoc } from '@atproto/lexicon'
import { XrpcClient } from '@atproto/xrpc'

const pingLexicon = {
  lexicon: 1,
  id: 'io.example.ping',
  defs: {
    main: {
      type: 'query',
      description: 'Ping the server',
      parameters: {
        type: 'params',
        properties: { message: { type: 'string' } },
      },
      output: {
        encoding: 'application/json',
        schema: {
          type: 'object',
          required: ['message'],
          properties: { message: { type: 'string' } },
        },
      },
    },
  },
} satisfies LexiconDoc

const xrpc = new XrpcClient('https://ping.example.com', [
  // Any number of lexicon here
  pingLexicon,
])

const res1 = await xrpc.call('io.example.ping', {
  message: 'hello world',
})
res1.encoding // => 'application/json'
res1.body // => {message: 'hello world'}

With a custom fetch handler

typescript
import { XrpcClient } from '@atproto/xrpc'

const session = {
  serviceUrl: 'https://ping.example.com',
  token: '<my-token>',
  async refreshToken() {
    const { token } = await fetch('https://auth.example.com/refresh', {
      method: 'POST',
      headers: { Authorization: `Bearer ${this.token}` },
    }).then((res) => res.json())

    this.token = token

    return token
  },
}

const sessionBasedFetch: FetchHandler = async (
  url: string,
  init: RequestInit,
) => {
  const headers = new Headers(init.headers)

  headers.set('Authorization', `Bearer ${session.token}`)

  const response = await fetch(new URL(url, session.serviceUrl), {
    ...init,
    headers,
  })

  if (response.status === 401) {
    // Refresh token, then try again.
    const newToken = await session.refreshToken()
    headers.set('Authorization', `Bearer ${newToken}`)
    return fetch(new URL(url, session.serviceUrl), { ...init, headers })
  }

  return response
}

const xrpc = new XrpcClient(sessionBasedFetch, [
  // Any number of lexicon here
  pingLexicon,
])

//

License

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

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


Overview (`services/bsky/README.md`)

bsky appview service

This is the service entrypoint for the bsky appview. The entrypoint command should run api.js with node, e.g. node api.js. The following env vars are supported:

  • BSKY_PUBLIC_URL - (required) the public url of the appview, e.g. https://api.bsky.app.
  • BSKY_DID_PLC_URL - (required) the url of the PLC service used for looking up did documents, e.g. https://plc.directory.
  • BSKY_DATAPLANE_URL - (required) the url where the backing dataplane service lives.
  • BSKY_SERVICE_SIGNING_KEY - (required) the public signing key in the form of a did:key, used for service-to-service auth. Advertised in the appview's did:web document.
  • BSKY_ADMIN_PASSWORDS - (alt. BSKY_ADMIN_PASSWORD) (required) comma-separated list of admin passwords used for role-based auth.
  • NODE_ENV - (recommended) for production usage, should be set to production. Otherwise all responses are validated on their way out. There may be other effects of not setting this to production, as dependencies may also implement debug modes based on its value.
  • BSKY_VERSION - (recommended) version of the bsky service. This is advertised by the health endpoint.
  • BSKY_PORT - (recommended) the port that the service will run on.
  • BSKY_IMG_URI_ENDPOINT - (recommended) the base url for resized images, e.g. https://cdn.bsky.app/img. When not set, sets-up an image resizing service directly on the appview.
  • BSKY_SERVER_DID - (recommended) the did of the appview service. When this is a did:web that matches the appview's public url, a did:web document is served.
  • BSKY_HANDLE_RESOLVE_NAMESERVERS - alternative domain name servers used for handle resolution, comma-separated.
  • BSKY_BLOB_CACHE_LOC - when BSKY_IMG_URI_ENDPOINT is not set, this determines where resized blobs are cached by the image resizing service.
  • BSKY_COURIER_URL - URL of courier service.
  • BSKY_COURIER_API_KEY - API key for courier service.
  • BSKY_BSYNC_URL - URL of bsync service.
  • BSKY_BSYNC_API_KEY - API key for bsync service.
  • BSKY_SEARCH_URL - (alt. BSKY_SEARCH_ENDPOINT) -
  • BSKY_LABELS_FROM_ISSUER_DIDS - comma-separated list of labelers to always use for record labels.
  • MOD_SERVICE_DID - the DID of the mod service, used to receive service authed requests.

Overview (`packages/internal/fetch-node/README.md`)

Node.js fetch utilities

Fetch helpers for Node.js 22+ that help protect services when fetching URLs supplied by users.

Usage

Create a fetch wrapper once and reuse it:

js
import { safeFetchWrap } from '@atproto-labs/fetch-node'

const safeFetch = safeFetchWrap()
const response = await safeFetch('https://api.github.com/repos/nodejs/node', {
  redirect: 'follow',
})
const data = await response.json()

By 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.

Pass 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.

Other helpers

  • unicastFetchWrap() restricts destinations to public IP addresses, including redirects, without adding the HTTPS, timeout, or response-size rules.
  • 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.
  • unicastLookup checks DNS results before connecting. isUnicastIpHostname(hostname) checks a literal IP address and returns undefined for a domain name.
  • This package also exports the helpers from @atproto-labs/fetch for working with requests, responses, errors, and fetch wrappers.

Temporary eMudhra TLS mitigation

emudhra-preload blocks HTTPS connections that use any of the eight intermediate keys affected by the eMudhra compromise, as listed in nodejs/node#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.

Node supports multiple --import flags. Load this before tracing and application code:

sh
node --import=@atproto-labs/fetch-node/emudhra-preload --import=@atproto/pds/telemetry service.js

Or copy src/emudhra-preload.mjs into your image and load it directly. It needs no other files or packages:

sh
node --import=/opt/mitigations/emudhra-preload.mjs --import=./tracing.mjs service.js

You 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.

The 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.

It 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.

Limits:

  • Keep certificate verification enabled: do not set rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0.
  • 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.
  • Workers and forked processes normally inherit the preload. Keep it enabled if you override execArgv or NODE_OPTIONS.
  • 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.

Overview (`packages/internal/handle-resolver/README.md`)

Universal Handle Resolver implementation for ATPROTO

This package provides a handle resolver implementation for ATPROTO. It is used
to resolve handles to their corresponding DID.

This package is meant to be used in any JavaScript environment that support the
fetch() function. Because APTORO handle resolution requires DNS resolution,
you will need to provide your own DNS resolution function when using this
package.

There are two main classes in this package:

  • AtprotoHandleResolver This implements the official ATPROTO handle resolution
    algorithm (and requires a DNS resolver).
  • AppViewHandleResolver This uses HTTP requests to the Bluesky AppView
    (bsky.app) to provide handle resolution.

Usage

From a front-end app

Since the ATPROTO handle resolution algorithm requires DNS resolution, and the
browser does not provide a built-in DNS resolver, this package offers two
options:

  • Delegate handle resolution to an AppView (AppViewHandleResolver). This is
    the recommended approach for front-end apps.
  • Use a DNS-over-HTTPS (DoH) server (DohHandleResolver). Prefer this method
    if you don't own an AppView and already have a DoH server that you trust.

Using an AppView:

[!CAUTION]
Use the Bluesky owned AppView (https://api.bsky.app/), or PDS
(https://bsky.social/), at your own risk. Using these servers in a
third-party application might expose your users' data (IP address) to Bluesky.
Bluesky might log the data sent to it when your app is resolving handles.
Bluesky might also change the API, or terms or use, at any time without
notice. Make sure you are compliant with the Bluesky terms of use as well as
any laws and regulations that apply to your use case.

ts
import { AppViewHandleResolver } from '@atproto-labs/handle-resolver'

const resolver = new AppViewHandleResolver({
  service: 'https://my-app-view.com/',
})
const did = await resolver.resolve('my-handle.bsky.social')

Using DNS-over-HTTPS (DoH) for DNS resolution:

[!CAUTION]
Using a DoH server that you don't own might expose your users' data to
the DoH server provider. The DoH server provider might log the data sent to it
by your app, allowing them to track which handles are being resolved by your
users. In the browser, it is recommended to use a DoH server that you own and
control. Or to implement your own AppView and use the AppViewHandleResolver
class.

[!NOTE]
Using the DohHandleResolver requires a DNS-over-HTTPS server that
supports the DNS-over-HTTPS protocol with "application/dns-json" responses.

ts
import { DohHandleResolver } from '@atproto-labs/handle-resolver'

// Also works with 'https://cloudflare-dns.com/dns-query'
const resolver = new DohHandleResolver('https://dns.google/resolve', {
  // Optional: Custom fetch function that will be used both for DNS resolution
  // and well-known resolution.
  fetch: globalThis.fetch.bind(globalThis),
})

const did = await resolver.resolve('my-handle.bsky.social')

From a Node.js app

[!NOTE]
On a Node.js backend, you will probably want to use the
"@atproto-labs/handle-resolver-node" package. The example below applies to
Node.js code running on a user's machine (e.g. through Electron).

ts
import { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'
import { resolveTxt } from 'node:dns/promises'

const resolver = new AtprotoHandleResolver({
  // Optional: Custom fetch function (used for well-known resolution)
  fetch: globalThis.fetch.bind(globalThis),

  resolveTxt: async (domain: string) =>
    resolveTxt(domain).then((chunks) => chunks.join('')),
})

Caching

Using a default, in-memory cache, in which items expire after 10 minutes:

ts
import {
  AppViewHandleResolver,
  CachedHandleResolver,
  HandleResolver,
  HandleCache,
} from '@atproto-labs/handle-resolver'

// See previous examples for creating a resolver
declare const sourceResolver: HandleResolver

const resolver = new CachedHandleResolver(sourceResolver)
const did = await resolver.resolve('my-handle.bsky.social')
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache

Using a custom cache:

ts
import {
  AppViewHandleResolver,
  CachedHandleResolver,
  HandleResolver,
  HandleCache,
} from '@atproto-labs/handle-resolver'

// See previous examples for creating a resolver
declare const sourceResolver: HandleResolver

const cache: HandleCache = {
  set(handle, did): Promise<void> {
    /* TODO */
  },
  get(handle): Promise<undefined | string> {
    /* TODO */
  },
  del(handle): Promise<void> {
    /* TODO */
  },
}

const resolver = new CachedHandleResolver(sourceResolver, cache)
const did = await resolver.resolve('my-handle.bsky.social')
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache

Observing resolution errors

By default, WellKnownHandleResolver (used internally by AtprotoHandleResolver
and AtprotoHandleResolverNode) returns null for non-abort errors,
indistinguishable from "no .well-known/atproto-did endpoint exists". That is
correct for the resolver contract, but it hides operational signals such as 5xx
upstream errors, SSRF blocks, redirect loops, or transient network failures.

To log or surface those failures, pass an onError handler when constructing the
resolver:

ts
const resolver = new AtprotoHandleResolverNode({
  onError: (err, { resolver, handle }) => {
    console.warn(
      `[handle-resolver:${resolver}] ${handle} failed to resolve:`,
      err,
    )
  },
})

Because the handler is set on the instance, it also fires for resolution done on
your behalf, which a per-call option cannot reach. For example, an OAuth client
you hand the resolver to:

ts
const client = new NodeOAuthClient({
  handleResolver: new AtprotoHandleResolverNode({ onError: logHandleError }),
  // ...
})

The handler is observational only: the resolver still returns null on failure,
so existing behavior does not change. Use it to:

  • Log resolution failures for debugging or alerting.
  • Surface specific failures to end users (e.g. distinguishing a misconfigured
    custom domain from a typo'd handle).
  • Emit telemetry for handle-host operators.

Overview (`packages/internal/opentelemetry-node/README.md`)

@atproto-labs/opentelemetry-node

OpenTelemetry bootstrap for atproto Node.js services.

This package wires up the OpenTelemetry Node SDK from the conventional OTEL_*
environment variables, so a service only has to supply its identity and the list
of instrumentations it wants.

Telemetry is opt-in via the exporter endpoint: the SDK stays disabled until an
OTLP endpoint is configured (OTEL_EXPORTER_OTLP_ENDPOINT or a signal-specific
variant). OTEL_SDK_DISABLED remains a kill switch.

Configuration

Everything beyond the service identity and instrumentation list is driven by the
conventional OTEL_* environment variables. See the OpenTelemetry docs for the
full set:

Usage

Load it before any instrumented module — typically through Node's --import flag:

ts
import { setup } from '@atproto-labs/opentelemetry-node'
import { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from '@atproto-labs/opentelemetry-node/conventions'
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'
import pkg from './package.json' with { type: 'json' }

setup(() => ({
  name: pkg.name,
  version: pkg.version,
  // Optional: override or extend the default resource attributes.
  defaultResourceAttributes: { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: 'staging' },
  // Service-specific instrumentations. The instrumentations common to atproto
  // services are always registered in addition to these.
  instrumentations: [new IORedisInstrumentation()],
}))

setup() takes a thunk so nothing is constructed when telemetry is disabled: the
options — and therefore the instrumentations — are only built once an OTLP endpoint
is configured.

XRPC routes on metrics

Pass the known XRPC methods the service may answer (including the ones it
proxies) as xrpcMethods. This allows to set the http.route attribute
correctly for these methods in the metrics. When xrpcMethods is omitted, the
http.route of the metrics is left as set by the Express instrumentation.

ts
// @NOTE **do not** import runtime code here (like `ids` from
// `@atproto/lex-cli`'s `./lexicon/lexicons.js`) as telemetry setup must run
// before any instrumented module. Importing from the `@atproto/lex` manifest
// (JSON) is fine.

import { setup } from '@atproto-labs/opentelemetry-node'
import pkg from './package.json' with { type: 'json' }
import lex from './lexicons.json' with { type: 'json' }

setup(() => ({
  name: pkg.name,
  version: pkg.version,
  xrpcMethods: lex.lexicons
    // @NOTE we only care about methods, which, by convention, have a verb
    // followed by a capitalized noun (e.g., `getFeed`). Reducing the list
    // helps keep the attribute low-cardinality.
    .filter((v) => /[A-Z]/.test(v)),
}))

This sets the http.route attribute of the HTTP server duration metric to
/xrpc/<nsid> for these methods, and to /xrpc/{unknown} for any other NSID.
The list is what keeps the attribute low-cardinality: any client
can make up an NSID. Span names aren't affected.

The instrumentations common to atproto services (HTTP with XRPC-aware span naming,
Express, Undici, Pino with log correlation, and Node runtime metrics) are always
registered alongside the ones you supply. The
@atproto-labs/opentelemetry-node/instrumentation entrypoint exposes
getDefaultAtprotoInstrumentations() if you need that list directly.

The @atproto-labs/opentelemetry-node/conventions entrypoint re-exports
@opentelemetry/semantic-conventions plus the atproto-specific constants: the
XRPC attribute keys (ATTR_XRPC_METHOD, ATTR_XRPC_PROXIED,
ATTR_XRPC_PROXY), the bsync stash attribute keys (ATTR_BSYNC_NAMESPACE,
ATTR_BSYNC_OPERATION), and RPC_CALL_DURATION_BUCKETS. The
@atproto-labs/opentelemetry-node/util entrypoint exposes
statusCodeToString(), which renders a Connect status as the snake_case string
rpc.response.status_code expects.

Both sides of an RPC need to agree on these, so they live here rather than in
either service — otherwise the caller and the callee end up depending on each
other just to label a metric.

License

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

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

Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see the original announcement.


Overview (`packages/internal/rolldown-plugin-bundle-manifest/README.md`)

@atproto-labs/rolldown-plugin-bundle-manifest

This Rolldown plugin allows to generate a (JSON) manifest containing the output
files of a Rolldown build. The manifest will look as follows:

json
{
  "main.js": {
    "type": "chunk",
    "mime": "application/javascript",
    "dynamicImports": [],
    "isDynamicEntry": false,
    "isEntry": true,
    "name": "main",
    "sha256": "<sha256-hash>",
    "data": "<base64-encoded-contents>"
  },
  "main.js.map": {
    "type": "asset",
    "mime": "application/json",
    "sha256": "<sha256-hash>",
    "data": "<base64-encoded-contents>"
  },
  "main.css": {
    "type": "asset",
    "mime": "text/css",
    "sha256": "<sha256-hash>",
    "data": "<base64-encoded-contents>"
  }
  // ... more entries as needed
}

This manifest will typically be useful for a backend service that serves the
frontend assets, as it can be used to determine the correct Content-Type and
and file integrity (via the SHA-256 hash), without having to read the files
themselves.

Usage

js
// rolldown.config.js

import { bundleManifest } from '@atproto-labs/rolldown-plugin-bundle-manifest'

export default {
  input: 'src/index.js',
  output: {
    dir: 'dist',
    format: 'es',
  },
  plugins: [
    bundleManifest({
      name: 'bundle-manifest.json',

      // Optional: should the asset data be embedded (as base64 string) in the manifest?
      data: false,
    }),
  ],
}

Options

  • name (string): The name of the manifest file. Defaults to bundle-manifest.json.
  • data (boolean): Whether to embed the asset data in the manifest. Defaults to false.

Example

js
const assetManifest = require('./dist/bundle-manifest.json')

const app = express()

app.use((req, res, next) => {
  const asset = assetManifest[req.path.slice(1)]
  if (!asset) return next()

  res.setHeader('Content-Type', asset.mime)
  res.setHeader('Content-Length', asset.data.length)

  res.end(Buffer.from(asset.data, 'base64'))
})

app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    buildCSP(assetManifest), // Not provided here
  )

  // Serve the index.html file
  res.sendFile('index.html')
})

License

MIT


Overview (`packages/lex/lex-builder/README.md`)

@atproto/lex-builder

Generates TypeScript schemas from Lexicon documents. This is the engine behind the lex build command.

typescript
import { build } from '@atproto/lex-builder'

await build({ lexicons: './lexicons', out: './src/lexicons' })

Most projects should use the lex build CLI from @atproto/lex rather than calling this package directly.

License

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

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


Overview (`packages/lex/lex-cbor/README.md`)

@atproto/lex-cbor

CBOR (DRISL) encoding and decoding for the AT Protocol data model, as used for repository storage and signing.

typescript
import { decode, encode, cidForLex } from '@atproto/lex-cbor'

const bytes = encode(someLexValue)
const value = decode(bytes)
const cid = await cidForLex(someLexValue)

Part of the @atproto/lex SDK family. Unlike the JSON codec, this package is not re-exported by @atproto/lex and must be installed directly.

License

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

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


Overview (`packages/lex/lex-client/README.md`)

@atproto/lex-client

Type-safe HTTP (XRPC) client for Lexicon-defined APIs: the Client class for working with a user's PDS (records) in the context of an authenticated session.

Authentication is supplied through an Agent, implemented by @atproto/oauth-client or, for scripts and bots, @atproto/lex-password-session.

This is a building block of @atproto/lex, which re-exports it -- prefer depending on that package.

License

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

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


Overview (`packages/lex/lex-data/README.md`)

@atproto/lex-data

Core types and utilities for the AT Protocol data model: LexValue and friends, CIDs, bytes, blob references, and string-length helpers.

The data model extends JSON with two additional structures — CIDs (content-addressed links) and raw bytes — which are encoded as JSON by @atproto/lex-json and as CBOR by @atproto/lex-cbor.

This is a building block of @atproto/lex, which re-exports it — prefer depending on that package.

License

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

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


Overview (`packages/lex/lex-document/README.md`)

@atproto/lex-document

Validation of Lexicon documents themselves — the JSON files written in the Lexicon schema definition language.

Where @atproto/lex-schema validates data against a schema, this package validates the schema documents, and turns them into runtime schemas by resolving refs across documents.

Part of the @atproto/lex SDK family, used by lex-builder, lex-installer and lex-resolver.

License

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

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


Overview (`packages/lex/lex-installer/README.md`)

@atproto/lex-installer

Package manager for Lexicon documents. This is the engine behind the lex install command: it resolves Lexicons by NSID, writes them to a local directory, and tracks their versions (CIDs) in a lexicons.json manifest.

typescript
import { install } from '@atproto/lex-installer'

await install({
  lexicons: './lexicons',
  manifest: './lexicons.json',
  add: ['app.bsky.feed.post'],
  save: true,
})

Most projects should use the lex install CLI from @atproto/lex rather than calling this package directly.

Resolution sources

Each Lexicon is resolved from, in order of priority:

  1. An explicit at:// URI passed as an addition — fetched (and verified) from that repo.

  2. An explicit local file passed as an addition — any value that is a file:// URI or
    contains a slash (./app.bsky.feed.post.json, file://../shared/my.json) is read from disk
    (relative to the cwd); its NSID is taken from the document's id.

  3. The manifest's resolvers — an ordered list of local override strategies consulted for
    every dependency NSID before the network. Currently only directory is supported:

    jsonc
    {
      "version": 1,
      "lexicons": ["com.example.foo"],
      "resolvers": [
        {
          "type": "directory",
          "path": "../../lexicons", // relative to this lexicons.json
          "include": ["com.example.*"], // optional NSID globs (default: all)
          "exclude": [], // optional NSID globs this resolver never answers for
        },
      ],
      "resolutions": {
        "com.example.foo": {
          "uri": "file://../../lexicons/com/example/foo.json",
          "cid": "...",
        },
      },
    }

    Resolvers are priority-ordered (first match wins) and are not reordered by manifest
    normalization. A directory resolver maps an NSID to /<a>//<c>.json.

  4. The network — the always-last fallback: NSID → DNS _lexicon.<authority> → DID → signed
    com.atproto.lexicon.schema record.

[!NOTE]
A future { "type": "repo", ... } resolver (resolving from specific DIDs rather than DNS
discovery) is planned but not yet implemented.

Lexicons resolved from a local file (sources 2 and 3 above) are symlinked into the output
lexicons/ directory rather than copied, so edits to the source file are reflected without
reinstalling. The symlink is written relative to its destination for portability. As an exception,
if the resolved destination path equals the source path, the file is left untouched. Their manifest
lock uri is a file:// path relative to the manifest, keeping it portable across checkouts.
Network-resolved Lexicons are written as plain JSON files and locked with their at:// URI.

License

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

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


Overview (`packages/lex/lex-json/README.md`)

@atproto/lex-json

JSON encoding and decoding for the AT Protocol data model, including the $link (CID) and $bytes representations used by XRPC.

Provides 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.

This is a building block of @atproto/lex, which re-exports it — prefer depending on that package.

License

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

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


Overview (`packages/lex/lex-password-session/README.md`)

@atproto/lex-password-session

Password-based session authentication for AT Protocol Lexicons. See the Changelog for version history.

bash
npm install @atproto/lex-password-session
  • Session management with automatic token refresh
  • Hooks for persisting and monitoring session state
  • PDS endpoint discovery from DID documents
  • Two-factor authentication support

[!IMPORTANT]

This package is currently in preview. The API and features are subject to change before the stable release.

What is this?

@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:

  1. Creating sessions with username/password credentials
  2. Automatic token refresh when access tokens expire
  3. Session persistence through lifecycle hooks
  4. Graceful logout with server-side session cleanup
typescript
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'
import * as app from './lexicons/app.js'

// Login with credentials
const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
  onUpdated: (data) => saveToStorage(data),
  onDeleted: (data) => clearStorage(data.did),
})

const client = new Client(session)

// Make authenticated requests
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: session.did,
})

Quick Start

1. Install the package

bash
npm install @atproto/lex-password-session @atproto/lex-client

2. Login and make requests

typescript
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'your-handle.bsky.social',
  password: 'your-app-password',
})

const client = new Client(session)

// Make authenticated API calls
console.log('Logged in as:', session.did)

PasswordSession

The PasswordSession class manages password-based authentication sessions.

Login

Create a new session with username and password:

typescript
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social', // handle or email
  password: 'app-password',
  onUpdated: (data) => {
    // Persist session for later restoration
    localStorage.setItem('session', JSON.stringify(data))
  },
  onDeleted: () => {
    localStorage.removeItem('session')
  },
})

console.log('Logged in as:', session.did)

The login() method throws on failure. For expected errors like invalid credentials, an XrpcResponseError is thrown. For 2FA requirements, a LexAuthFactorError is thrown.

Two-Factor Authentication

[!CAUTION]

Two-factor authentication only applies when using main account credentials, which is strongly discouraged. Password authentication should be used with app passwords only because they are designed for programmatic access (bots, scripts, CLI tools). For user-facing applications, use OAuth via @atproto/oauth-client which provides better security and user control.

If the account has 2FA enabled, login will throw a LexAuthFactorError:

typescript
import {
  PasswordSession,
  LexAuthFactorError,
} from '@atproto/lex-password-session'

async function loginWith2FA(
  identifier: string,
  password: string,
  authFactorToken?: string,
): Promise<PasswordSession> {
  try {
    return await PasswordSession.login({
      service: 'https://bsky.social',
      identifier,
      password,
      authFactorToken,
      onUpdated: (data) => saveToStorage(data),
      onDeleted: (data) => removeFromStorage(data.did),
    })
  } catch (err) {
    if (err instanceof LexAuthFactorError && !authFactorToken) {
      // 2FA required - prompt user for code
      const token = await promptUserFor2FACode(err.message)
      return loginWith2FA(identifier, password, token)
    }
    throw err
  }
}

Resume Session

Restore a previously saved session:

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

// Load session from storage
const savedSession: SessionData = JSON.parse(localStorage.getItem('session')!)

// Resume the session (automatically refreshes tokens)
const session = await PasswordSession.resume(savedSession, {
  onUpdated: (data) => {
    localStorage.setItem('session', JSON.stringify(data))
  },
  onDeleted: () => {
    localStorage.removeItem('session')
  },
})

console.log('Session resumed for:', session.did)

// Access session properties
console.log(session.did) // User's DID
console.log(session.handle) // User's handle
console.log(session.destroyed) // false (session is active)

[!NOTE]

resume() automatically calls refresh() to ensure the session is valid and tokens are current.

Logout

End the session and notify the server:

typescript
await session.logout()

After logout:

  • The onDeleted hook is called
  • The session is marked as destroyed (session.destroyed === true)
  • Further requests will throw 'Logged out'

Static Delete

Delete a session without creating a session instance:

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

const data: SessionData = JSON.parse(localStorage.getItem('session')!)

// Delete the session on the server
await PasswordSession.delete(data)

This is useful for cleanup scenarios where you don't need to make additional requests.

Create Account

Create a new account and get an authenticated session:

typescript
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.createAccount(
  {
    handle: 'alice.bsky.social',
    email: '[email protected]',
    password: 'secure-password',
  },
  {
    service: 'https://bsky.social',
    onUpdated: (data) => saveToStorage(data),
    onDeleted: (data) => removeFromStorage(data.did),
  },
)

console.log('Account created:', session.did)

Session Hooks

Hooks provide callbacks for session lifecycle events. All hooks receive the session instance as this context.

onUpdated

Called when the session is successfully created or refreshed:

typescript
const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
  onUpdated(data) {
    // `this` is the PasswordSession instance
    console.log('Session updated for:', this.did)

    // Persist the updated session
    saveSession(data)
  },
})

[!IMPORTANT]

Requests are blocked while onUpdated is running. Keep this callback fast to avoid delays.

onUpdateFailure

Called when token refresh fails due to transient errors (network issues, server unavailability):

typescript
{
  onUpdateFailure(data, error) {
    console.warn('Token refresh failed:', error.message)
    // Session may still be valid - consider retry logic
  }
}

onDeleted

Called when the session is terminated (logout or server-side invalidation):

typescript
{
  onDeleted(data) {
    console.log('Session ended for:', data.did)
    clearPersistedSession(data.did)
    redirectToLogin()
  }
}

onDeleteFailure

Called when logout fails due to transient errors:

typescript
{
  onDeleteFailure(data, error) {
    console.error('Logout failed:', error.message)
    // Consider queuing for retry to avoid orphaned sessions
    queueLogoutRetry(data)
  }
}

[!WARNING]

Ignoring delete failures can leave sessions active on the server. Implement retry logic for security-sensitive applications.

Session Data

The SessionData type contains all data needed to authenticate and restore sessions:

typescript
type SessionData = {
  // Session credentials and user info from createSession response
  accessJwt: string
  refreshJwt: string
  did: string
  handle: string
  email?: string
  emailConfirmed?: boolean
  didDoc?: object
  // ... other fields from createSession

  // Original service URL used for login
  service: string
}

Error Handling

The PasswordSession class uses exception-based error handling:

typescript
import {
  PasswordSession,
  LexAuthFactorError,
} from '@atproto/lex-password-session'
import { XrpcResponseError } from '@atproto/lex-client'

try {
  const session = await PasswordSession.login({
    service: 'https://bsky.social',
    identifier: 'alice.bsky.social',
    password: 'wrong-password',
  })
} catch (err) {
  if (err instanceof LexAuthFactorError) {
    console.error('2FA required')
  } else if (err instanceof XrpcResponseError) {
    switch (err.error) {
      case 'AuthenticationRequired':
        console.error('Invalid credentials')
        break
      case 'AccountTakedown':
        console.error('Account has been suspended')
        break
      default:
        console.error('Login failed:', err.message)
    }
  } else {
    throw err
  }
}

Common error codes:

Error Code Description
AuthenticationRequired Invalid username or password
AuthFactorTokenRequired 2FA code needed
AccountTakedown Account suspended
ExpiredToken Token has expired (on refresh)
InvalidToken Token is invalid

Using with Client

The PasswordSession implements the Agent interface and can be used directly with Client:

typescript
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'
import * as app from './lexicons/app.js'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
})

const client = new Client(session)

// The client automatically uses the session for authentication
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: client.assertDid,
})

// Tokens are automatically refreshed when expired
const timeline = await client.call(app.bsky.feed.getTimeline, {
  limit: 50,
})

// Create records
await client.create(app.bsky.feed.post, {
  text: 'Hello from lex-password-session!',
  createdAt: new Date().toISOString(),
})

The session handles:

  • Adding Authorization headers to requests
  • Detecting expired tokens (401 responses or ExpiredToken errors)
  • Automatically refreshing tokens and retrying failed requests
  • Routing requests to the correct PDS based on DID document

License

MIT or Apache2


Overview (`packages/lex/lex-resolver/README.md`)

@atproto/lex-resolver

Resolves Lexicon documents from the AT Protocol network by NSID.

typescript
import { LexResolver } from '@atproto/lex-resolver'

const resolver = new LexResolver({})
const { lexicon, uri, cid } = await resolver.get('app.bsky.feed.post')

Resolution looks up the _lexicon.<authority> DNS TXT record to find the controlling DID, resolves that DID to a PDS, then fetches and cryptographically verifies the Lexicon record. Hooks allow caching at the fetch layer.

Part of the @atproto/lex SDK family, used by @atproto/lex-installer to implement lex install.

License

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

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


Overview (`packages/lex/lex-rpc/README.md`)

@atproto/lex-rpc

Type-safe HTTP (XRPC) utilities for Lexicon-defined APIs: exposes low-level xrpc() / xrpcSafe() functions for making requests based on Lexicon derived schemas.

License

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

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


Overview (`packages/lex/lex-schema/README.md`)

@atproto/lex-schema

Runtime schema system for AT Protocol Lexicons. This is what the TypeScript code generated by lex build is built on, and it provides the l namespace for declaring schemas by hand.

Schemas expose validation helpers ($parse, $validate, $matches, $assert, …) and implement the Standard Schema interface.

This is a building block of @atproto/lex, which re-exports it — prefer depending on that package.

License

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

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


Overview (`packages/lex/lex-server/README.md`)

@atproto/lex-server

Request router for Atproto Lexicon protocols and schemas. See the Changelog for version history.

bash
npm install @atproto/lex-server
  • Type-safe request routing based on Lexicon schemas
  • Support for queries, procedures, and WebSocket subscriptions
  • Built on Web standard Request/Response APIs (portable across runtimes)
  • Custom authentication with credential passing
  • Graceful shutdown with AsyncDisposable pattern

[!IMPORTANT]

This package is currently in preview. The API and features are subject to change before the stable release.

What is this?

Building AT Protocol servers requires handling XRPC requests, validating inputs against Lexicon schemas, managing authentication, and supporting real-time subscriptions. @atproto/lex-server automates this by:

  1. Routing requests to type-safe handlers based on Lexicon schemas
  2. Automatically validating request parameters and bodies
  3. Providing a flexible authentication system with custom strategies
  4. Supporting WebSocket subscriptions with backpressure handling
typescript
import { LexRouter } from '@atproto/lex-server'
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
import * as app from './lexicons/app.js'

const router = new LexRouter({ upgradeWebSocket })
  .add(app.bsky.actor.getProfile, async ({ params }) => {
    const profile = await db.getProfile(params.actor)
    return { body: profile }
  })
  .add(app.bsky.feed.post.create, {
    auth: requireAuth,
    handler: async ({ credentials, input }) => {
      const result = await db.createPost(credentials.did, input.body)
      return { body: result }
    },
  })

await serve(router, { port: 3000 })

Quick Start

1. Install the package

bash
npm install @atproto/lex-server

2. Generate Lexicon schemas

Use @atproto/lex to generate TypeScript schemas from your Lexicon definitions:

bash
lex install app.bsky.actor.getProfile
lex build

3. Create a router and add handlers

typescript
import { LexRouter, LexServerError } from '@atproto/lex-server'
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
import * as app from './lexicons/app.js'

const router = new LexRouter({ upgradeWebSocket })

// Add a query handler
router.add(app.bsky.actor.getProfile, async ({ params }) => {
  const profile = await db.getProfile(params.actor)
  if (!profile) {
    throw new LexServerError(404, {
      error: 'NotFound',
      message: 'Profile not found',
    })
  }
  return { body: profile }
})

// Start the server
const server = await serve(router, { port: 3000 })
console.log('Server listening on port 3000')

LexRouter

The LexRouter class is the core of @atproto/lex-server. It routes XRPC requests to type-safe handlers based on Lexicon schemas.

Creating a Router

typescript
import { LexRouter } from '@atproto/lex-server'
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'

const router = new LexRouter({
  // Required for WebSocket subscriptions (Node.js)
  upgradeWebSocket,

  // Optional: Add logging or error reporting for unexpected errors in handlers
  onHandlerError: ({ error, request, method }) => {
    console.error(`Error in ${method.nsid}:`, error)
  },

  // Optional: WebSocket backpressure settings
  highWaterMark: 250_000, // bytes (default: 250KB)
  lowWaterMark: 50_000, // bytes (default: 50KB)
})

Adding Routes

Routes are added using the .add() method, which accepts a Lexicon schema and a handler:

typescript
// Simple handler (no authentication)
router.add(schema, async ({ params, input, request }) => {
  return { body: result }
})

// Handler with authentication
router.add(schema, {
  auth: async ({ request, params }) => credentials,
  handler: async ({ params, input, credentials, request }) => {
    return { body: result }
  },
})

The router supports method chaining:

typescript
const router = new LexRouter()
  .add(app.bsky.actor.getProfile, profileHandler)
  .add(app.bsky.feed.getTimeline, timelineHandler)
  .add(app.bsky.feed.post.create, postHandler)

Handler Context

Handlers receive a context object with the following properties:

typescript
type LexRouterHandlerContext<Method, Credentials> = {
  credentials: Credentials // Result of auth function (undefined if no auth)
  input: InferMethodInput<Method> // Parsed request body (procedures only)
  params: InferMethodParams<Method> // Parsed URL query parameters
  request: Request // Original Web Request object
  connection?: ConnectionInfo // Network connection info
}

Handler Output

Handlers can return various output formats:

typescript
// JSON response (encoding inferred from schema)
return { body: { key: 'value' } }

// With custom encoding
return { encoding: 'text/plain', body: 'Hello, world!' }

// With response headers
return { body: data, headers: { 'x-custom': 'value' } }

// Empty response (200 OK with no body)
return {}

// Custom Response object (full control)
return new Response(body, { status: 201, headers })

// Proxy Response
return fetch('https://example.com/data')

Queries and Procedures

Query Handler

Queries handle GET requests and receive parameters from the URL query string:

typescript
import * as app from './lexicons/app.js'

router.add(app.bsky.actor.getProfile, async ({ params }) => {
  // params.actor is typed and validated
  const profile = await db.getProfile(params.actor)
  return { body: profile }
})

Procedure Handler

Procedures handle POST requests and receive a request body:

typescript
router.add(app.bsky.feed.post.create, async ({ input }) => {
  // input.body contains the parsed and validated request body
  const post = await db.createPost(input.body)
  return { body: { uri: post.uri, cid: post.cid } }
})

Binary Payloads

For endpoints that accept or return binary data:

typescript
// Binary input
router.add(app.example.uploadBlob, async ({ input }) => {
  // input.body is a Request object for streaming
  // input.encoding contains the content-type
  const blob = await input.body.arrayBuffer()
  return { body: { cid: await store(blob) } }
})

// Binary output
router.add(app.example.getBlob, async ({ params }) => {
  const stream = await getBlob(params.cid)
  return {
    encoding: 'application/octet-stream',
    body: stream,
  }
})

Subscriptions

Subscriptions provide real-time data over WebSocket connections. Handlers are async generators that yield messages:

typescript
import { LexRouter, LexError } from '@atproto/lex-server'
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
import { scheduler } from 'node:timers/promises'

const router = new LexRouter({
  upgradeWebSocket, // Required for WebSocket support in nodejs
})

router.add(com.example.stream, async function* ({ params, request }) {
  const { cursor = 0, limit = 10 } = params
  const { signal } = request

  for (let i = 0; i < limit; i++) {
    // Yield messages to the client
    yield com.example.stream.message.$build({
      data: `Message ${cursor + i}`,
      cursor: cursor + i,
    })

    // Wait between messages (respects abort signal)
    await scheduler.wait(1000, { signal })
  }

  // Throwing a LexError closes the connection with an error frame
  throw new LexError('LimitReached', `Limit of ${limit} messages reached`)
})

Messages are CBOR-encoded and sent as WebSocket binary frames. The router handles:

  • WebSocket upgrade negotiation
  • Backpressure management
  • Graceful connection cleanup
  • Error frame encoding

Authentication

Custom Authentication

Authentication is implemented through the auth function in handler configs:

typescript
import { LexError, LexServerAuthError } from '@atproto/lex-server'

type Credentials = { did: string; scope: string[] }

const requireAuth = async ({
  request,
}: {
  request: Request
}): Promise<Credentials> => {
  const header = request.headers.get('authorization')
  if (!header?.startsWith('Bearer ')) {
    throw new LexServerAuthError(
      'AuthenticationRequired',
      'Bearer token required',
      {
        Bearer: { realm: 'api' },
      },
    )
  }

  const token = header.slice(7)
  const session = await verifyToken(token)
  if (!session) {
    throw new LexServerAuthError(
      'InvalidToken',
      'Token is invalid or expired',
      {
        Bearer: { realm: 'api', error: 'invalid_token' },
      },
    )
  }

  return { did: session.did, scope: session.scope }
}

// Use with handlers
router.add(app.bsky.feed.post.create, {
  auth: requireAuth,
  handler: async ({ credentials, input }) => {
    // credentials.did is available here
    const post = await db.createPost(credentials.did, input.body)
    return { body: post }
  },
})

The auth function:

  1. Is called before parsing the request body
  2. Receives params, request, and connection info
  3. Should throw LexServerError (or LexServerAuthError) on failure
  4. Returns credentials that are passed to the handler

WWW-Authenticate Headers

Use LexServerAuthError to include WWW-Authenticate headers in error responses:

typescript
import { LexServerAuthError } from '@atproto/lex-server'

// Simple Bearer challenge
throw new LexServerAuthError('AuthenticationRequired', 'Login required', {
  Bearer: { realm: 'api' },
})
// WWW-Authenticate: Bearer realm="api"

// Multiple schemes
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
  Bearer: { realm: 'api', scope: 'read write' },
  Basic: { realm: 'api' },
})
// WWW-Authenticate: Bearer realm="api", scope="read write", Basic realm="api"

// Token68 format
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
  Bearer: 'token68value',
})
// WWW-Authenticate: Bearer token68value

Error Handling

LexServerError

Throw LexServerError to return structured XRPC error responses:

typescript
import { LexServerError } from '@atproto/lex-server'

router.add(app.bsky.actor.getProfile, async ({ params }) => {
  try {
    const profile = await db.getProfile(params.actor)
    return { body: profile }
  } catch (cause) {
    throw new LexServerError(
      404,
      {
        error: 'NotFound',
        message: 'Profile not found',
      },
      {
        'cache-control': 'max-age=600',
      },
      { cause },
    )
  }
})

Error responses follow the XRPC format:

json
{
  "error": "NotFound",
  "message": "Profile not found"
}

LexServerAuthError

LexServerAuthError extends LexServerError with WWW-Authenticate header support:

typescript
import { LexServerAuthError } from '@atproto/lex-server'

throw new LexServerAuthError('AuthenticationRequired', 'Invalid credentials', {
  Bearer: { realm: 'api' },
})

This returns a 401 response with the WWW-Authenticate header.

Error Handler Callback

Use onHandlerError to log or report unexpected errors:

typescript
const router = new LexRouter({
  onHandlerError: async ({ error, request, method }) => {
    // Log errors (excluding expected abort signals)
    console.error(`Error in ${method.nsid}:`, error)
    await reportToSentry(error)
  },
})

[!NOTE]

The callback is only invoked for unexpected errors, not for LexError instances or request aborts.

Node.js Server

The @atproto/lex-server/nodejs subpath provides Node.js-specific utilities.

serve()

Start a server and begin listening:

typescript
import { serve } from '@atproto/lex-server/nodejs'

const server = await serve(router, { port: 3000 })
console.log('Server listening on port 3000')

// Graceful shutdown
await server.terminate()

The server supports AsyncDisposable:

typescript
await using server = await serve(router, { port: 3000 })
// Server is automatically terminated when scope exits

Options:

typescript
type StartServerOptions = {
  port?: number
  host?: string
  gracefulTerminationTimeout?: number // ms to wait for connections to close
}

createServer()

Create a server without starting it:

typescript
import { createServer } from '@atproto/lex-server/nodejs'

const server = createServer(router, {
  gracefulTerminationTimeout: 5000,
})

server.listen(3000, () => {
  console.log('Server listening')
})

toRequestListener()

Convert a handler to an Express/Connect-compatible middleware:

typescript
import express from 'express'
import { toRequestListener } from '@atproto/lex-server/nodejs'

const app = express()

// Mount the XRPC router
app.use(toRequestListener(router.fetch))

app.listen(3000)

upgradeWebSocket()

Required for WebSocket subscription support in Node.js:

typescript
import { LexRouter } from '@atproto/lex-server'
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'

const router = new LexRouter({ upgradeWebSocket })

Advanced Usage

Custom Response Objects

Return a Response object for full control over the response (disables any kind
of validation of the body):

typescript
router.add(schema, async ({ params }) => {
  if (params.redirect) {
    return Response.redirect('https://example.com', 302)
  }

  return Response.json(
    { custom: true },
    {
      status: 201,
      headers: {
        'X-Custom-Header': 'value',
      },
    },
  )
})

Response Headers

Add headers to responses:

typescript
router.add(schema, async ({ params }) => {
  return {
    body: { data: 'value' },
    headers: {
      'Cache-Control': 'public, max-age=3600',
      'X-Request-Id': crypto.randomUUID(),
    },
  }
})

Connection Info

Access network connection information:

typescript
router.add(schema, async ({ connection }) => {
  console.log('Remote address:', connection?.remoteAddr?.hostname)
  console.log('Local address:', connection?.localAddr?.hostname)
  return { body: { status: 'ok' } }
})

Connection info structure:

typescript
type ConnectionInfo = {
  localAddr?: {
    hostname: string
    port: number
    transport: 'tcp' | 'udp'
  }
  remoteAddr?: {
    hostname: string
    port: number
    transport: 'tcp' | 'udp'
  }
}

License

MIT or Apache2


Overview (`packages/lex/lex/README.md`)

Type-safe Lexicon tooling for AT Protocol data.

  • Fetch and manage Lexicon schemas, generate TypeScript validators
  • Compile-time and runtime type safety for AT Protocol data structures
  • Fully typed XRPC client with authentication support
  • Tree-shaking and composition friendly
typescript
// Build and validate data with generated utilities

const newPost = app.bsky.feed.post.$build({
  text: 'Hello, world!',
  createdAt: new Date().toISOString(),
})

app.bsky.actor.profile.$validate({
  $type: 'app.bsky.actor.profile',
  displayName: 'Ha'.repeat(32) + '!',
}) // Error: grapheme too big (maximum 64, got 65) at $.displayName
typescript
// Trivially make type-safe XRPC requests towards a service

const profile = await xrpc('https://api.bsky.app', app.bsky.actor.getProfile, {
  params: { actor: 'pfrazee.com' },
})
typescript
// Manipulate records with the Client API in the context of an authenticated session

const client = new Client(oauthSession)

await client.create(app.bsky.feed.post, {
  text: 'Hello, world!',
  createdAt: new Date().toISOString(),
})

const posts = await client.list(app.bsky.feed.post, { limit: 10 })

Quick Start

1. Install Lexicons

Install the Lexicon schemas you need for your application:

bash
lex install app.bsky.feed.post app.bsky.feed.like

This creates:

  • lexicons.json - manifest tracking installed Lexicons and their versions (CIDs)
  • lexicons/ - directory containing the Lexicon JSON files

[!NOTE]

The lex command might conflict with other binaries installed on your system.
If that happens, you can also run the CLI using ts-lex, pnpm exec lex or
npx @atproto/lex.

2. Verify and commit installed Lexicons

Make sure to commit the lexicons.json manifest and the lexicons/ directory containing the JSON files to version control.

bash
git add lexicons.json lexicons/
git commit -m "Install Lexicons"

3. Build TypeScript schemas

Generate TypeScript schemas from the installed Lexicons:

bash
lex build

This generates TypeScript files in ./src/lexicons (by default) with type-safe validation, type guards, and builder utilities.

[!TIP]

If you wish to customize the output location or any other build options, pass the appropriate flags to the lex build command. See the TypeScript Schemas section for available options.

[!NOTE]

The generated TypeScript files don't need to be committed to version control. Instead, they can be generated during your project's build step. See Workflow Integration for details.

To avoid committing generated files, add the output directory to your .gitignore:

bash
echo "/src/lexicons" >> .gitignore

4. Use in your code

typescript
import { xrpc } from '@atproto/lex'
import { app } from './lexicons/index.js'

const profile = await xrpc('https://api.bsky.app', app.bsky.actor.getProfile, {
  params: { actor: 'pfrazee.com' },
})

Lexicon Schemas

The lex install command resolves Lexicon schemas and manages them locally (in the lexicons/ directory by default). It also updates the lexicons.json manifest file to track installed Lexicons and their versions.

bash
# Install Lexicons and update lexicons.json (default behavior)
lex install app.bsky.feed.post

# Install all Lexicons from lexicons.json manifest
lex install

# Install specific Lexicons without updating manifest
lex install --no-save app.bsky.feed.post app.bsky.actor.profile

# Update (re-fetch) all installed Lexicons to latest versions
lex install --update

# Fetch any missing Lexicons and verify against manifest
lex install --ci

Options:

  • --manifest - Path to lexicons.json manifest file (default: ./lexicons.json)
  • --no-save - Don't update lexicons.json with installed lexicons (save is enabled by default)
  • --update - Update all installed lexicons to their latest versions by re-resolving and re-installing them
  • --ci - Error if the installed lexicons do not match the CIDs in the lexicons.json manifest
  • --lexicons <dir> - Directory containing lexicon JSON files (default: ./lexicons)

Local resolution (`resolvers`)

Beyond the network, lexicons.json can declare an ordered resolvers array of
local override strategies, consulted (in order, first match wins) for every
dependency before the network fallback. This lets a package vendor Lexicons from
a local directory. Local-file resolutions are symlinked into the lexicons/
directory (not copied) and locked with a file:// URI relative to the manifest.

jsonc
{
  "version": 1,
  "lexicons": ["com.example.foo"],
  "resolvers": [
    // path relative to lexicons.json; optional include/exclude NSID globs
    { "type": "directory", "path": "../../lexicons" },
    // use a specific repo for resolving certain NSIDs (instead of performing the _lexicon network resolution of the authority)
    { "type": "repo", "repo": "bsky.app", "include": ["app.bsky.*"] },
  ],
  "resolutions": {/* ...file:// or at:// locks... */},
}

See the @atproto/lex-installer README for the full resolution order and the planned repo resolver.

TypeScript Schemas

After installing Lexicon JSON files, use the lex build command to generate TypeScript schemas. These generated schemas provide type-safe validation, type guards, and builder utilities for working with AT Protocol data structures.

bash
lex build --lexicons ./lexicons --out ./src/lexicons

Options:

  • --lexicons <dir> - Directory containing lexicon JSON files (default: ./lexicons)
  • --out <dir> - Output directory for generated TypeScript (default: ./src/lexicons)
  • --clear - Clear output directory before generating
  • --override - Override existing files (has no effect with --clear)
  • --no-pretty - Don't run prettier on generated files (prettier is enabled by default)
  • --ignore-errors - Skip files that fail to parse or compile instead of aborting the build
  • --ignore-invalid-lexicons - Skip lexicon files that fail validation instead of exiting with an error
  • --exclude - List of strings or regex patterns to exclude lexicon documents by their IDs
  • --include - List of strings or regex patterns to include lexicon documents by their IDs
  • --lib - Package name of the library to import the lex schema utility "l" from (default: @atproto/lex)
  • --import-ext <ext> - File extension to use for import statements in generated files (default: .js). Use --import-ext "" to generate extension-less imports
  • --file-ext <ext> - File extension to use for generated files (default: .ts)
  • --index-file - Generate an "index" file that re-exports all root-level namespaces (disabled by default)
  • --defs-export - When some definitions conflict with child namespaces, export lexicon definitions under a separate $defs namespace (e.g. com.example.foo.$defs)
  • --no-default-export - Disable generation of a default export of the main schema in each schema's namespace file (default exports are enabled by default; see Tree-Shaking)

Generated Schema Structure

Each Lexicon generates a TypeScript module with:

  • Type definitions - TypeScript types extracted from the schema
  • Schema instances - Runtime validation objects with methods
  • Exported utilities - Convenience functions for common operations

Type definitions

You can extract TypeScript types from the generated schemas for use in you application:

typescript
import * as app from './lexicons/app.js'

function renderPost(p: app.bsky.feed.post.Main) {
  console.log(p.$type) // 'app.bsky.feed.post'
  console.log(p.text)
}

Token definitions also export their string value in PascalCase, using the same
name as their string literal type. The token's camelCase export remains the
runtime schema:

typescript
import * as app from './lexicons/app.js'

const requestLess = app.bsky.feed.defs.RequestLess
// 'app.bsky.feed.defs#requestLess'

type RequestLess = app.bsky.feed.defs.RequestLess

app.bsky.feed.defs.requestLess // TokenSchema
app.bsky.feed.defs.requestLess.$validate('foo') // throws
app.bsky.feed.defs.requestLess.$safeValidate('foo') // ValidationFailure
app.bsky.feed.defs.requestLess.$token === requestLess // true

Building data

It is recommended to use the generated builders to create data that conforms to the schema. TypeScript ensures that all required fields are present at compile time.

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

// variable type will be inferred as "app.bsky.feed.post.Main"
const post = app.bsky.feed.post.$build({
  // No need to specify $type when using $build
  text: 'Hello, world!',
  createdAt: l.currentDatetimeString(),
})

// For runtime validation, use $parse()/$validate() instead
const postWithDefaults = app.bsky.feed.post.$parse(post)
app.bsky.feed.post.$validate(post)

Validation Helpers

Generated namespaces expose a handful of $-prefixed helpers bound to the namespace's main schema. They come in two groups:

  • Universal validation helpers are available on every schema's main: $matches, $assert, $check, $parse, $safeParse, $validate, $safeValidate (and $cast / $ifMatches). These work for records, typed objects, queries, procedures, and subscriptions.
  • Record / typed-object helpers are only emitted for record and typed-object schemas: $type, $build, $isTypeOf.

In addition, every generated namespace file exports a top-level $nsid constant containing the NSID of the lexicon document:

typescript
import * as app from './lexicons/app.js'

console.log(app.bsky.feed.defs.$nsid) // 'app.bsky.feed.defs'

The Schema instance itself (for example app.bsky.feed.post.main) also exposes the underlying methods both with and without the $ prefix (e.g. main.parse() and main.$parse()).

Record / typed-object helpers

`$type` - Type Identifier

Returns the $type string of the schema (only available on record and typed-object schemas):

typescript
import * as app from './lexicons/app.js'

console.log(app.bsky.feed.post.$type) // 'app.bsky.feed.post'
console.log(app.bsky.actor.defs.profileViewBasic.$type) // 'app.bsky.actor.defs#profileViewBasic'

Prefer $type over hard-coding the equivalent string literal in your code. The constant is emitted exactly once per schema in the generated namespace file, so every reference reuses the same string instance. Inlining 'app.bsky.feed.post' everywhere instead leaks the same string into every call site, increases bundle size, and creates a typo-prone source of drift between your code and the schema.

`$build(data)` - Build with Defaults

Builds data by adding the $type property and properly types the result. This also allows to declare a variable with the correct type without having to explicitly specify it.

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

// The type of the "like" variable will be "app.bsky.feed.like.Main" (no need to explicitly specify the type)
const like = app.bsky.feed.like.$build({
  subject: {
    uri: 'at://did:plc:abc/app.bsky.feed.post/123',
    cid: 'bafyrei...',
  },
  createdAt: l.currentDatetimeString(),
})

[!NOTE]

$build() does not perform validation, and expects properly typed input data - use $parse() if you need validation.

`$isTypeOf(data)` - Type Discriminator

Discriminates (pre-validated) data based on its $type property, without re-validating. This is especially useful when working with union types:

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

declare const data:
  app.bsky.feed.post.Main | app.bsky.feed.like.Main | l.Unknown$TypedObject

// Discriminate by $type without re-validating
if (app.bsky.feed.post.$isTypeOf(data)) {
  // data is a post
}

Universal validation helpers

`$matches(data)` - Type Guard

Returns true if data matches the schema, false otherwise. Acts as a TypeScript type guard:

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const data: unknown = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
}

if (app.bsky.feed.post.$matches(data)) {
  // TypeScript knows data is a Post here
  console.log(data.text)
}

[!NOTE]

Performs validation so $isTypeOf is preferred for pre-validated & properly typed data.

`$assert(data)` - Type-Narrowing Assertion

Throws if data does not match the schema. When the schema is statically known (e.g. app.bsky.feed.post), TypeScript narrows the type of data after the call:

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const data: unknown = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
}

app.bsky.feed.post.$assert(data)

// TypeScript now knows data is app.bsky.feed.post.Main
console.log(data.text)

For library code that operates on a schema parameter whose type cannot be fully expressed, see Validating Generic Schemas with $check.

`$parse(data)` - Parse and Validate

Validates and returns typed data, throwing an error if validation fails:

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

try {
  const post = app.bsky.feed.post.$parse({
    $type: 'app.bsky.feed.post',
    text: 'Hello!',
    createdAt: l.currentDatetimeString(),
  })

  // post is now typed and validated
  console.log(post.text)
} catch (error) {
  console.error('Validation failed:', error)
}

[!NOTE]

The $parse method will apply defaults defined in the schema for optional fields, as well as data coercion (e.g., CID strings to Cid types). This means that the returned value might be different from the input data if defaults were applied. Use $validate() for value validation.

`$validate(data)` - Validate a value against the schema

Validates an existing value against a schema, returning the value itself if, and only if, it already matches the schema (ie. without applying defaults or coercion).

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const value = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
}

// Throws if no valid
const result = app.bsky.feed.post.$validate(value)

value === result // true

`$safeParse(data, options?)` - Parse a value against a schema and get the resulting value

Returns a detailed validation result object without throwing:

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const result = app.bsky.feed.post.$safeParse({
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
})

if (result.success) {
  console.log('Valid post:', result.value)
} else {
  console.error('Validation failed:', result.error)
}

All schema methods that perform validation ($parse, $safeParse, $validate, $safeValidate) accept an optional { strict } option. When strict is false, validation becomes more lenient: datetime string format checks are relaxed (e.g. datetimes without timezones are accepted; other string formats remain strict), blob MIME type and size constraints are not enforced, non-raw CIDs are allowed in blob references, and legacy blob reference format (objects with cid and mimeType properties) is accepted. This is primarily used internally by the XRPC client when strictResponseProcessing is disabled, but can also be used directly:

typescript
// Strict mode (default) - rejects datetime without timezone
app.bsky.feed.post.$safeParse(data) // { strict: true } is the default

// Non-strict mode - accepts more lenient data
app.bsky.feed.post.$safeParse(data, { strict: false })

Data Model

The AT Protocol uses a data model that extends JSON with two additional data structures: CIDs (content-addressed links) and bytes (for raw data). This data model can be encoded either as JSON for XRPC (HTTP API) or as CBOR for storage and authentication (see @atproto/lex-cbor).

Types

The package exports TypeScript types and type guards for working with the data model:

typescript
import type {
  LexValue,
  LexMap,
  LexScalar,
  TypedLexMap,
  Cid,
} from '@atproto/lex'
import { isLexValue, isLexMap, isTypedLexMap, isCid } from '@atproto/lex'

// LexScalar: number | string | boolean | null | Cid | Uint8Array
// LexValue:  LexScalar | LexValue[] | { [key: string]?: LexValue }
// LexMap:    { [key: string]?: LexValue }
// TypedLexMap: LexMap & { $type: string }
// Cid: Content Identifier (link by hash)

if (isTypedLexMap(data)) {
  console.log(data.$type) // some string
}

JSON Encoding

In JSON, CIDs are represented as {"$link": "bafyrei..."} and bytes as {"$bytes": "base64..."}. This package provides utilities to parse and stringify data model values to/from JSON:

typescript
import { Cid, lexParse, lexStringify, jsonToLex, lexToJson } from '@atproto/lex'

// Parse JSON string → data model (decodes $link and $bytes)
const parsed = lexParse<{
  ref: Cid
  data: Uint8Array
}>(`{
  "ref": { "$link": "bafyrei..." },
  "data": { "$bytes": "SGVsbG8sIHdvcmxkIQ==" }
}`)

assert(isCid(parsed.ref))
assert(parsed.data instanceof Uint8Array)

const someCid = lexParse<Cid>('{"$link": "bafyrei..."}')
const someBytes = lexParse<Uint8Array>('{"$bytes": "SGVsbG8sIHdvcmxkIQ=="}')

// Data model → JSON string (encodes CIDs and bytes)
const json = lexStringify({ ref: someCid, data: someBytes })

// Convert between parsed JSON objects and data model values
const lex = jsonToLex({
  ref: { $link: 'bafyrei...' }, // Converted to Cid
  data: { $bytes: 'SGVsbG8sIHdvcmxkIQ==' }, // Converted to Uint8Array
})

const obj = lexToJson({
  ref: someCid, // Converted to { $link: string }
  data: someBytes, // Converted to { $bytes: string }
})

CBOR Encoding

Use @atproto/lex-cbor to encode/decode the data model to/from CBOR (DRISL) format for storage and authentication:

typescript
import { encode, decode } from '@atproto/lex-cbor'
import type { LexValue } from '@atproto/lex'

// Encode data model to CBOR bytes
const cborBytes = encode(someLexValue)

// Decode CBOR bytes to data model
const lexValue: LexValue = decode(cborBytes)

Making simple XRPC Requests

XRPC (short for "Lexicon RPC") is the set of HTTP conventions used by AT Protocol for client-server and server-server communication. Endpoints follow the pattern /xrpc/<nsid>, where the NSID maps to a Lexicon schema that defines the request and response types. XRPC has three method types: queries (HTTP GET) for read operations, procedures (HTTP POST) for mutations and subscriptions (WebSockets) for real-time updates.

The xrpc() and xrpcSafe() functions can be used to make simple XRPC requests. They are typically used in places that don't require an authenticated session, or when more granular control over the request/response is needed. For most use cases, the Client API provides a more ergonomic way to work with XRPC in the context of an authenticated session.

typescript
import { xrpc, xrpcSafe } from '@atproto/lex'
import * as com from './lexicons/com.js'

const response = await xrpc(
  'https://bsky.network',
  com.atproto.identity.resolveHandle,
  {
    params: { handle: 'atproto.com' },
    headers: { 'user-agent': 'MyApp/1.0.0' },
  },
)

response.status // number
response.headers // Headers
response.body.did // `did:${string}:${string}`

// Or use the safe variant (returns errors instead of throwing)
const result = await xrpcSafe(
  'https://bsky.network',
  com.atproto.identity.resolveHandle,
  {
    params: { handle: 'atproto.com' },
    signal: AbortSignal.timeout(5000), // Abort after 5 seconds
  },
)

if (result.success) {
  console.log(result.body)
} else {
  console.error(result.error) // XRPC error code
  console.error(result.message) // Error message
}

Both xrpc() and xrpcSafe() accept validateRequest, validateResponse, and strictResponseProcessing options to control validation and strictness per-call. See Validation and Strictness Options for details.

Client API

The Client class provides high-level helpers for common AT Protocol "repo" operations: create(), get(), put(), delete(), list(), uploadBlob(), and more. A Client instance is typically useful for making requests in the context of an authenticated user session, as it automatically handles headers and provides default values based on the authenticated user's DID.

A Client instance is also useful to encapsulate configuration for a specific service, by specifying the service option (for proxying) and labelers option (for content labeling). These act as defaults for the client's requests and can be overridden — or disabled with null — on a per-request basis, allowing a single client to talk both to a proxied service (e.g. an AppView) and directly to the user's PDS.

Creating a Client

Unauthenticated Client

Just provide the service URL:

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

const client = new Client('https://public.api.bsky.app')

Authenticated Client with OAuth

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

// Setup OAuth client (see @atproto/oauth-client documentation)
const oauthClient = new OAuthClient({/* ... */})
const session = await oauthClient.restore(userDid)

// Create authenticated client
const client = new Client(session)

For detailed OAuth setup, see the @atproto/oauth-client documentation.

Authenticated Client with Password

For CLI tools, scripts, and bots, you can use password-based authentication with @atproto/lex-password-session:

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

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'xxxx-xxxx-xxxx-xxxx', // App password
  onUpdated: (data) => saveToStorage(data),
  onDeleted: (data) => clearStorage(data.did),
})

const client = new Client(session)

For detailed password session setup, see the @atproto/lex-password-session documentation.

Client with Service Proxy (authenticated only)

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

// Route requests through a specific service
const client = new Client(session, {
  service: 'did:web:api.bsky.app#bsky_appview',
})

Validation and Strictness Options

The Client constructor accepts options to control request/response validation and how invalid Lex data is handled. These defaults apply to all XRPC calls made through the client, and can be overridden per-call via client.call(), client.xrpc() or client.xrpcSafe().

typescript
const client = new Client(session, {
  // Validate requests against the method's input schema (default: false)
  validateRequest: true,

  // Validate responses against the method's output schema (default: true)
  validateResponse: true,

  // Strictly process responses according to Lex encoding rules. When set to
  // false, accepts responses containing invalid Lex data such as floats or
  // malformed $bytes/$link objects (default: true)
  strictResponseProcessing: false,
})
  • validateRequest — When true, outgoing request bodies are validated against the Lexicon input schema before sending. Useful in development to catch errors early. Default: false.
  • validateResponse — When true, incoming response bodies are validated against the Lexicon output schema. Disabling this can improve performance when you trust the upstream service. Default: true.
  • strictResponseProcessing — When true (default), the client will strictly process responses according to Lex encoding rules, rejecting responses containing invalid Lex data (e.g. floating-point numbers, malformed $bytes or $link objects). When false, the client accepts such responses in a lenient mode: invalid values are returned as-is rather than being rejected or converted, datetime string format checks become more lenient (e.g. datetimes without timezones are accepted) while other string formats remain strict, blob MIME type and size constraints are not enforced, and legacy blob reference format (objects with cid and mimeType properties) is accepted. Default: true.

Core Methods

`client.call()`

Call procedures or queries defined in Lexicons.

typescript
import * as app from './lexicons/app.js'

// Query (GET request)
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: 'pfrazee.com',
})

// Procedure (POST request)
const result = await client.call(app.bsky.feed.sendInteractions, {
  interactions: [/* ... */],
})

// With options
const timeline = await client.call(
  app.bsky.feed.getTimeline,
  {
    limit: 50,
  },
  {
    signal: abortSignal,
  },
)

`client.create()`

Create a new record un the authenticated user's repo.

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const result = await client.create(app.bsky.feed.post, {
  text: 'Hello, world!',
  createdAt: l.currentDatetimeString(),
})

console.log(result.uri) // at://did:plc:...
console.log(result.cid)

Options:

  • rkey - Custom record key (auto-generated if not provided)
  • validate - Tri-state instruction to the PDS. true forces server-side schema validation, false explicitly disables it, and undefined (default) lets the PDS decide (it validates only collections whose schemas it knows)
  • validateRequest - Validate the record locally against schema before submitting the request
  • swapCommit - CID for optimistic concurrency control

`client.get()`

Retrieve a record.

typescript
import * as app from './lexicons/app.js'

// No need to specify the "rkey" for records with literal keys (e.g. profile)
const profile = await client.get(app.bsky.actor.profile)

console.log(profile.displayName)
console.log(profile.description)

For records with non-literal keys:

typescript
const post = await client.get(app.bsky.feed.post, {
  rkey: '3jxf7z2k3q2',
})

`client.put()`

Update an existing record.

typescript
import * as app from './lexicons/app.js'

await client.put(app.bsky.actor.profile, {
  displayName: 'New Name',
  description: 'Updated bio',
})

Options:

  • rkey - Record key (required for non-literal keys)
  • validate - Tri-state instruction to the PDS. true forces server-side schema validation, false explicitly disables it, and undefined (default) lets the PDS decide (it validates only collections whose schemas it knows)
  • validateRequest - Validate the record locally against schema before submitting the request
  • swapCommit - Expected repo commit CID
  • swapRecord - Expected record CID

`client.delete()`

Delete a record.

typescript
import * as app from './lexicons/app.js'

await client.delete(app.bsky.feed.post, {
  rkey: '3jxf7z2k3q2',
})

`client.list()`

List records in a collection.

typescript
import * as app from './lexicons/app.js'

const result = await client.list(app.bsky.feed.post, {
  limit: 50,
  reverse: true,
})

for (const record of result.records) {
  console.log(record.uri, record.value.text)
}

// Records that failed local schema validation are returned separately
for (const invalid of result.invalid) {
  console.warn('Invalid record:', invalid)
}

// Pagination
if (result.cursor) {
  const nextPage = await client.list(app.bsky.feed.post, {
    cursor: result.cursor,
    limit: 50,
  })
}

The result includes:

  • records - Records that successfully validated against the schema
  • invalid - Records returned by the server that failed local schema validation (raw LexMap values)
  • cursor - Pagination cursor (if more results are available)

`client.applyWrites()`

Perform an atomic batch of create, update, and delete operations in a single request.

typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const response = await client.applyWrites((op) => [
  // Create a new post
  op.create(app.bsky.feed.post, {
    text: 'Hello, world!',
    createdAt: l.currentDatetimeString(),
  }),

  // Update profile
  op.update(app.bsky.actor.profile, {
    displayName: 'Alice',
    description: 'Updated bio',
  }),

  // Delete an existing post by rkey
  op.delete(app.bsky.feed.post, {
    rkey: '3jxf7z2k3q2',
  }),
])

// Check results
for (const result of response.body.results) {
  console.log(result.uri, result.cid)
}

Options:

  • repo - Repository identifier (defaults to authenticated user's DID)
  • validate - Tri-state instruction to the PDS. true forces server-side schema validation, false explicitly disables it, and undefined (default) lets the PDS decide (it validates only collections whose schemas it knows)
  • swapCommit - CID for optimistic concurrency control

[!NOTE]

All operations in an applyWrites() call are atomic - they either all succeed or all fail together. This is useful for maintaining consistency when making multiple related changes.

Error Handling

By default, all client methods throw errors when requests fail. For more ergonomic error handling, the client provides "Safe" variants that return errors instead of throwing them.

Safe Methods

The xrpcSafe() method catches errors and returns them as part of the result type instead of throwing:

XrpcFailure Type

The xrpcSafe() method returns a union type that includes the success case (XrpcResponse) and failure cases (XrpcFailure):

text
/* Detailed source-code truncated for AI context efficiency. */

The XrpcFailure<M> type is a union of three error classes:

  1. XrpcResponseError - The server responded with a 4xx/5xx error status code. This is used for all error responses from the upstream server.

  2. XrpcInvalidResponseError - The upstream server returned a 2xx/3xx that does not comply with XRPC specifications for successful responses. A sub-class, XrpcResponseValidationError, is used for payload schema validation failures specifically.

  3. XrpcInternalError - Client-side errors (network failures, timeouts, etc.)

Authentication Methods

`client.did`

Get the authenticated user's DID.

typescript
const did = client.did // Returns Did | undefined

`client.assertAuthenticated()`

Assert that the client is authenticated (throws if not).

typescript
client.assertAuthenticated()
// After this call, TypeScript knows client.did is defined
const did = client.did // Type: Did (not undefined)

`client.assertDid`

Get the authenticated user's DID, asserting that the client is authenticated.

typescript
const did = client.assertDid // Type: Did (throws if not authenticated)

This is equivalent to calling client.assertAuthenticated() followed by accessing client.did, but provides a more concise way to get the DID when you know authentication is required.

Labeler Configuration

Configure content labelers for moderation.

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

// Global app-level labelers
Client.configure({
  appLabelers: ['did:plc:labeler1', 'did:plc:labeler2'],
})

// Client-specific labelers
const client = new Client(session, {
  labelers: ['did:plc:labeler3'],
})

// Add labelers dynamically
client.addLabelers(['did:plc:labeler4'])

// Replace all labelers
client.setLabelers(['did:plc:labeler5'])

// Clear labelers
client.clearLabelers()

The client's labelers act as a default for its requests. A per-request labelers option replaces that default for that request, and labelers: null disables the atproto-accept-labelers header entirely:

typescript
// Uses the client's labelers
await client.call(app.bsky.actor.getProfile, { actor })

// Uses only 'did:plc:other' for this request
await client.xrpc(app.bsky.actor.getProfile, {
  params: { actor },
  labelers: ['did:plc:other'],
})

// No atproto-accept-labelers header for this request (except appLabelers,
// which are applied unless explicitly disabled)
await client.xrpc(app.bsky.actor.getProfile, {
  params: { actor },
  labelers: null,
})

App-level labelers (appLabelers) are always added to the atproto-accept-labelers header with the ;redact param. They default to the static Client.appLabelers (set via Client.configure()), and can be overridden through the client's or a request's appLabelers option (appLabelers: null disables them for that client or request).

The same defaulting logic applies to the service option (atproto-proxy header): a per-request service replaces the client's default, and service: null disables proxying for that request.

Low-Level XRPC

For advanced use cases, use client.xrpc() to get the full response (headers, status, body):

typescript
import * as app from './lexicons/app.js'

const response = await client.xrpc(app.bsky.feed.getTimeline, {
  params: { limit: 50 },
  signal: abortSignal,
  headers: { 'custom-header': 'value' },
})

console.log(response.status)
console.log(response.headers)
console.log(response.body)

Validation and strictness options (validateRequest, validateResponse, strictResponseProcessing) can also be passed per-call to override the client defaults:

typescript
const response = await client.xrpc(app.bsky.feed.getTimeline, {
  params: { limit: 50 },
  strictResponseProcessing: false, // Accept non-strict Lex data for this call
  validateResponse: false, // Skip schema validation for this call
})

Utilities

Various utilities for working with CIDs, datetime strings, string lengths, language tags, and low-level JSON encoding are exported from the package:

text
/* Detailed source-code truncated for AI context efficiency. */

Datetime Strings

Many AT Protocol records (such as posts, likes, and follows) include a createdAt field that expects a valid DatetimeString. While new Date().toISOString() produces a string that looks like a valid datetime, it is not guaranteed to always conform to the AT Protocol's datetime format requirements (for example, Date objects representing dates before year 0 or after year 9999 will produce non-conforming strings). To ensure correctness and type safety, use the DatetimeString utilities exported from @atproto/lex:

  • toDatetimeString(date: Date) - Converts a Date object into a valid DatetimeString, throwing an InvalidDatetimeError if the date cannot be represented as a valid AT Protocol datetime.
  • asDatetimeString(input: string) - Validates and casts an arbitrary string to DatetimeString, throwing an InvalidDatetimeError if the string does not conform.
  • isDatetimeString(input) - Type guard that returns true if the input is a valid DatetimeString.
  • ifDatetimeString(input) - Returns the input as a DatetimeString if valid, or undefined otherwise.
  • currentDatetimeString() - Returns the current date and time as DatetimeString.
typescript
import { l } from '@atproto/lex'

// Convert a Date object to a DatetimeString (or throws)
const someDate = new Date('2024-01-15T12:30:00Z')
const now = l.toDatetimeString(someDate)

// Get the current datetime as a DatetimeString
const now = l.currentDatetimeString()

// Validate and cast an existing string
const dt = l.asDatetimeString('2024-01-15T12:30:00.000Z')

// Type guard for conditional checks
if (l.isDatetimeString(someString)) {
  // someString is now typed as DatetimeString
}

Advanced Usage

Workflow Integration

Development Workflow

Add these scripts to your package.json:

json
{
  "scripts": {
    "update-lexicons": "lex install --update --save",
    "postinstall": "lex install --ci",
    "prebuild": "lex build",
    "build": "# Your build command here"
  }
}

This ensures that:

  1. Lexicons are verified against the manifest after every npm install or pnpm install.
  2. TypeScript schemas are built before your project is built.
  3. You can easily update lexicons with npm run update-lexicons or pnpm update-lexicons.

Tree-Shaking

The generated TypeScript code is structured to be tree-shakeable, but the way you reference schemas has a meaningful impact on the final bundle size. There are several ways to refer to a generated schema, and each comes with different trade-offs.

Namespace notation

The most ergonomic style is to use a namespace import and reference schemas through dotted paths:

typescript
import * as com from './lexicons/com.js'

await client.call(com.atproto.repo.getRecord, {/* ... */})

This style is convenient and reads naturally as it mirrors the NSID of the schema. However, it produces the largest bundles. From the bundler's point of view, com.atproto.repo.getRecord is the whole schema namespace (which contains the main schema as well as helpers, and any other definitions). The bundler cannot know that client.call() only consumes the main schema, so it has to keep the rest of the namespace alive in the bundle.

Explicit `.main` reference

You can mitigate the bundle-size cost by explicitly naming the main definition:

typescript
import * as com from './lexicons/com.js'

await client.call(com.atproto.repo.getRecord.main, {/* ... */})

This lets the bundler drop the sibling definitions inside getRecord that aren't referenced. The drawback is that it leaks an implementation detail: the main segment of the path. In Lexicon, main is typically implicit:

  • Records use a $type of app.bsky.feed.post (no #main)
  • XRPC endpoints are exposed as /xrpc/com.atproto.repo.getRecord (no main)

So writing .main in application code feels verbose compared to how Lexicons are normally referred to.

Direct named import from the schema file

You can also import the main schema directly from the file that defines it:

typescript
import { main as getRecord } from './lexicons/com/atproto/repo/getRecord.js'

await client.call(getRecord, {/* ... */})

This produces equally small bundles as the explicit .main reference, but it still surfaces the main identifier: you have to know to import main and likely rename it.

To make the small-bundle path also the ergonomic path, every namespace file generated by lex build re-exports the main schema as its default export:

typescript
// generated file: ./lexicons/com/atproto/repo/getRecord.js
export * from './getRecord.defs.js'
export { main as default } from './getRecord.defs.js'

This means you can write:

typescript
import getRecord from './lexicons/com/atproto/repo/getRecord.js'
import post from './lexicons/app/bsky/feed/post.js'

await client.call(getRecord, {/* ... */})
await client.create(post, {/* ... */})

This is the most bundle-friendly style: the bundler only pulls in the main schema, and the import name doesn't have to mention main at all. This helps keeping application code aligned with how Lexicons are usually identified.

Drawbacks of the default export

The default re-export is enabled by default but has two minor drawbacks:

  1. It is one additional property on the namespace module, which can very slightly increase bundle size if you also use the namespace in some places.
  2. Any Lexicon document whose path segment is literally default (for example a hypothetical com.example.records.default) would conflict with the generated default export.

If either of these matters for your use case, you can disable the generation of default exports with the --no-defaultExport flag:

bash
lex build --no-defaultExport

Summary

Style Bundle size Ergonomics
com.atproto.repo.getRecord (namespace) Largest Best: matches the NSID
com.atproto.repo.getRecord.main Small Leaks the main identifier
import { main as getRecord } from '.../getRecord.js' Small Verbose, leaks main
import getRecord from '.../getRecord.js' Small Concise, no main in source

For libraries and applications where bundle size matters (typically anything shipped to a browser), prefer the default-import style. For scripts, tests, and server-side code where the bundle size of generated schemas is not a concern, the namespace style is perfectly fine.

Blob references

In AT Protocol, binary data (blobs) are referenced using blob references, which include metadata like MIME type and size. These references allow PDSs to determine which binary data ("files") is referenced by records.

TypedBlobRef: The Current Standard

The current standard format for blob references is TypedBlobRef:

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

const blobRef: TypedBlobRef = {
  $type: 'blob',
  ref: parseCid('bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku'),
  mimeType: 'image/png',
  size: 12345,
}

When creating new blobs, always use the TypedBlobRef format. This is the format returned by client.uploadBlob() and expected by PDS endpoints.

LegacyBlobRef: Historical Format

Historically, blob references used a simpler format without the $type property:

typescript
type LegacyBlobRef = {
  cid: string // CID as a string (not a Cid object)
  mimeType: string // No size property
}

Legacy blob references still exist in the AT Protocol network in older records created before the format migration. While new blobs should always be created as TypedBlobRef, your code must be prepared to handle both formats when reading existing data.

Working with Both Formats

The BlobRef type is a union that accepts both formats:

typescript
import {
  BlobRef,
  isBlobRef,
  isTypedBlobRef,
  isLegacyBlobRef,
} from '@atproto/lex'

// When reading data, always use BlobRef to handle both formats
function processBlobRef(blob: BlobRef) {
  if (isTypedBlobRef(blob)) {
    console.log('Modern blob:', blob.ref, blob.mimeType, blob.size)
  } else if (isLegacyBlobRef(blob)) {
    console.log('Legacy blob:', blob.cid, blob.mimeType)
  }
}

// Or use the isBlobRef type guard which accepts both
if (isBlobRef(value)) {
  // value is BlobRef (either TypedBlobRef or LegacyBlobRef)
}

Helper functions work with both formats:

typescript
import {
  getBlobCid,
  getBlobCidString,
  getBlobMime,
  getBlobSize,
} from '@atproto/lex'

// These utilities work with both TypedBlobRef and LegacyBlobRef
const cid = getBlobCid(blobRef) // Returns Cid object
const cidStr = getBlobCidString(blobRef) // Returns string (optimized)
const mime = getBlobMime(blobRef) // Returns mimeType
const size = getBlobSize(blobRef) // Returns number | undefined (legacy refs lack size)

[!IMPORTANT]

Validation behavior with legacy blobs:

  • In strict mode (strict: true, the default): Legacy blob references are rejected during validation. Use this mode when you control the data source and expect only modern blobs.
  • In non-strict mode (strict: false): Legacy blob references are accepted. This mode is used automatically when strictResponseProcessing: false is set on the Client, allowing your application to handle older records from the network gracefully.
typescript
// Strict mode (default) - rejects legacy blobs
schema.$safeParse(data) // { strict: true }

// Non-strict mode - accepts legacy blobs
schema.$safeParse(data, { strict: false })

Actions

Actions are composable functions that combine multiple XRPC calls into higher-level operations. They can be invoked using client.call() just like Lexicon methods, making them a powerful tool for building library-style APIs on top of the low-level client.

What are Actions?

An Action is a function with this signature:

typescript
type Action<Input, Output> = (
  client: Client,
  input: Input,
  options: ActionOptions,
) => Output | Promise<Output>

Actions receive:

  • client - The Client instance (to make XRPC calls)
  • input - The input data for the action
  • options - ActionOptions (currently just { signal?: AbortSignal })

Using Actions

Actions are called using client.call(), the same method used for XRPC queries and procedures:

typescript
import { Action, Client, l } from '@atproto/lex'
import * as app from './lexicons/app.js'

// Define an action
export const likePost: Action<
  { uri: string; cid: string },
  { uri: string; cid: string }
> = async (client, { uri, cid }, options) => {
  client.assertAuthenticated()

  const result = await client.create(
    app.bsky.feed.like,
    {
      subject: { uri, cid },
      createdAt: l.currentDatetimeString(),
    },
    options,
  )

  return result
}

// Use the action
const client = new Client(session)
const like = await client.call(likePost, {
  uri: 'at://did:plc:abc/app.bsky.feed.post/123',
  cid: 'bafyreiabc...',
})

Composing Multiple Operations

Actions excel at combining multiple XRPC calls:

typescript
import { Action, Client } from '@atproto/lex'
import * as app from './lexicons/app.js'

type Preference = app.bsky.actor.defs.Preferences[number]

// Action that reads, modifies, and writes preferences
const upsertPreference: Action<Preference, Preference[]> = async (
  client,
  newPref,
  options,
) => {
  // Read current preferences
  const { preferences } = await client.call(
    app.bsky.actor.getPreferences,
    options,
  )

  // Update the preference list
  const updated = [
    ...preferences.filter((p) => p.$type !== newPref.$type),
    newPref,
  ]

  // Save updated preferences
  await client.call(
    app.bsky.actor.putPreferences,
    { preferences: updated },
    options,
  )

  return updated
}

// Use it
await client.call(
  upsertPreference,
  app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
)

Higher-Order Actions

Actions can call other actions, enabling powerful composition:

typescript
import { Action } from '@atproto/lex'
import * as app from './lexicons/app.js'

type Preference = app.bsky.actor.defs.Preferences[number]

// Low-level action: update preferences with a function
const updatePreferences: Action<
  (prefs: Preference[]) => Preference[] | false,
  Preference[]
> = async (client, updateFn, options) => {
  const { preferences } = await client.call(
    app.bsky.actor.getPreferences,
    options,
  )

  const updated = updateFn(preferences)
  if (updated === false) return preferences

  await client.call(
    app.bsky.actor.putPreferences,
    { preferences: updated },
    options,
  )

  return updated
}

// Higher-level action: upsert a specific preference
const upsertPreference: Action<Preference, Preference[]> = async (
  client,
  pref,
  options,
) => {
  return updatePreferences(
    client,
    (prefs) => [...prefs.filter((p) => p.$type !== pref.$type), pref],
    options,
  )
}

// Even higher-level: enable adult content
const enableAdultContent: Action<void, Preference[]> = async (
  client,
  _,
  options,
) => {
  return upsertPreference(
    client,
    app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
    options,
  )
}

// Use the high-level action
await client.call(enableAdultContent)

Using a Single Client for Multiple Services

Because the client's service and labelers options are just defaults, a single Client instance can be used to talk to multiple services. This is the recommended pattern for SDK-style code: configure the client to talk to an AppView by default, and override service on a per-request basis when a request must reach another service — or the user's PDS directly (service: null).

typescript
import { Client } from '@atproto/lex'
import * as app from './lexicons/app.js'
import * as com from './lexicons/com.js'

const client = new Client(session, {
  // All requests are proxied to the AppView by default
  service: 'did:web:api.bsky.app#bsky_appview',
})

// Proxied to the AppView (client default)
const { feed } = await client.call(app.bsky.feed.getTimeline)

// Sent directly to the user's PDS (no atproto-proxy header)
const { body } = await client.xrpc(com.atproto.repo.getRecord, {
  service: null,
  params: {
    repo: client.assertDid,
    collection: 'app.bsky.actor.profile',
    rkey: 'self',
  },
})

// Proxied to a different service, just for this request
await client.xrpc(app.bsky.actor.getProfile, {
  service: 'did:web:com.example.other#other_service',
  params: { actor: client.assertDid },
})

[!NOTE]

The record helpers (create(), get(), put(), delete(), list(), createRecord(), getRecord(), putRecord(), deleteRecord(), listRecords(), applyWrites(), uploadBlob(), getBlob()) always target the user's PDS: they default to service: null and labelers: null, ignoring the client's instance-wide defaults (unless explicitly overridden in their options).

If you need clients with different configurations sharing the same authentication, create them from the same session (or reuse an existing client's agent):

typescript
const pdsClient = new Client(session)
const bskyClient = new Client(pdsClient.agent, {
  service: 'did:web:api.bsky.app#bsky_appview',
})

[!NOTE]

In previous version of this library, Client implemented the Agent interface, and its fetchHandler method could be used as the agent of another Client. This lead to confusing bugs (#5110) where the inner agent (client) would set atproto-proxy and atproto-accept-labelers headers on requests where the outer client explicitly set service: null and labelers: null. This is no longer the case: a Client can no longer be used as the agent of another Client. To share authentication between differently-configured clients, build them from the same session.

Building Library-Style APIs with Actions

Actions enable you to create high-level, convenience APIs similar to @atproto/api's Agent class. Here are patterns for common operations:

Creating Posts

typescript
import { Action, l } from '@atproto/lex'
import * as app from './lexicons/app.js'

type PostInput = Partial<app.bsky.feed.post.Main> &
  Omit<app.bsky.feed.post.Main, 'createdAt'>

export const post: Action<PostInput, { uri: string; cid: string }> = async (
  client,
  record,
  options,
) => {
  return client.create(
    app.bsky.feed.post,
    {
      ...record,
      createdAt: record.createdAt || l.currentDatetimeString(),
    },
    options,
  )
}

// Usage
await client.call(post, {
  text: 'Hello, AT Protocol!',
  langs: ['en'],
})

Following Users

typescript
import { Action, l } from '@atproto/lex'
import { AtUri } from '@atproto/syntax'
import * as app from './lexicons/app.js'

export const follow: Action<
  { did: string },
  { uri: string; cid: string }
> = async (client, { did }, options) => {
  return client.create(
    app.bsky.graph.follow,
    {
      subject: did,
      createdAt: l.currentDatetimeString(),
    },
    options,
  )
}

export const unfollow: Action<{ followUri: string }, void> = async (
  client,
  { followUri },
  options,
) => {
  const uri = new AtUri(followUri)
  await client.delete(app.bsky.graph.follow, {
    ...options,
    rkey: uri.rkey,
  })
}

// Usage
const { uri } = await client.call(follow, { did: 'did:plc:abc123' })
await client.call(unfollow, { followUri: uri })

Updating Profile with Retry Logic

text
/* Detailed source-code truncated for AI context efficiency. */

Packaging Actions as a Library

Create a collection of actions for your application:

typescript
// actions.ts
import { Action, Client } from '@atproto/lex'
import * as app from './lexicons/app.js'

export const post: Action</* ... */> = async (client, input, options) => {
  /* ... */
}
export const like: Action</* ... */> = async (client, input, options) => {
  /* ... */
}
export const follow: Action</* ... */> = async (client, input, options) => {
  /* ... */
}
export const updateProfile: Action</* ... */> = async (
  client,
  input,
  options,
) => {
  /* ... */
}

Usage:

typescript
import * as actions from './actions.js'

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

Best Practices for Actions

  1. Type Safety: Always provide explicit type parameters for Action
  2. Authentication: Use client.assertAuthenticated() when auth is required
  3. Abort Signals: Check options.signal?.throwIfAborted() between long operations
  4. Composition: Build complex actions from simpler ones
  5. Retries: Implement retry logic for operations with optimistic concurrency control
  6. Tree-shaking: Export actions individually to allow tree-shaking (instead of bundling them in a single class)

Standard Schema Compatibility

All generated schemas implement the Standard Schema interface (StandardSchemaV1), which means they can be used with any library or framework that supports Standard Schema, such as form validation libraries, API frameworks, and more.

Every Schema instance exposes a ~standard property conforming to the spec:

typescript
import * as app from './lexicons/app.js'

// Use with any Standard Schema-compatible library
const schema = app.bsky.feed.post

schema['~standard'].version // 1
schema['~standard'].vendor // '@atproto/lex-schema'

// Validate using the Standard Schema interface
const result = schema['~standard'].validate(someData)

if ('value' in result) {
  console.log(result.value) // Parsed and validated data
} else {
  console.error(result.issues)
}

When validated through the Standard Schema interface, schemas operate in "parse" mode, meaning transformations like defaults and coercions are applied to the output.

Validating Generic Schemas with `$check`

$check(data) is the non-narrowing counterpart to $assert(data): both throw when data does not match the schema, but $check does not refine the static type of its argument.

$check is rarely needed in application code — prefer $assert. It is intended for library-style code that takes a schema as a generic parameter, where TypeScript cannot satisfy the assertion-signature requirement and $assert produces the following error:

'schema' needs an explicit type annotation.
Assertions require every name in the call target to be declared with an explicit type annotation. ts(2775)

In that situation, switch to $check:

typescript
import type { Schema } from '@atproto/lex'

function ensureMatches<S extends Schema>(schema: S, data: unknown) {
  // schema.$assert(data) // ❌ ts(2775): needs an explicit type annotation
  schema.$check(data) // ✅ throws on invalid, no type narrowing
}

License

MIT or Apache2


Overview (`packages/oauth/oauth-client-browser/README.md`)

atproto OAuth Client for the Browser

This package provides a browser specific OAuth client implementation for
atproto. It implements all the OAuth features required by ATPROTO (PKCE, DPoP,
etc.).

@atproto/oauth-client-browser is designed for front-end applications that do
not have a backend server to manage OAuth sessions, a.k.a "Single Page
Applications" (SPA).

[!IMPORTANT]

When a backend server is available, it is recommended to use
@atproto/oauth-client-node
to manage OAuth sessions from the server side and use a session cookie to map
the OAuth session to the front-end. Because this mechanism allows the backend
to invalidate OAuth credentials at scale, this method is more secure than
managing OAuth sessions from the front-end directly. Thanks to the added
security, the OAuth server will provide longer lived tokens when issued to a
BFF (Backend-for-frontend).

Setup

Client ID

The client_id is what identifies your application to the OAuth server. It is
used to fetch the client metadata and to initiate the OAuth flow. The
client_id must be a URL that points to the client
metadata
.

Client Metadata

Your OAuth client metadata should be hosted at a URL that corresponds to the
client_id of your application. This URL should return a JSON object with the
client metadata. The client metadata should be configured according to the
needs of your application and must respect the ATPROTO spec.

json
{
  // Must be the same URL as the one used to obtain this JSON object
  "client_id": "https://my-app.com/client-metadata.json",
  "client_name": "My App",
  "client_uri": "https://my-app.com",
  "logo_uri": "https://my-app.com/logo.png",
  "tos_uri": "https://my-app.com/tos",
  "policy_uri": "https://my-app.com/policy",
  "redirect_uris": ["https://my-app.com/callback"],
  "scope": "atproto",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "application_type": "web",
  "dpop_bound_access_tokens": true
}

The client metadata is used to instantiate an OAuth client. There are two ways
of doing this:

  1. Either you "burn" the metadata into your application:

    typescript
    import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
    
    const client = new BrowserOAuthClient({
      clientMetadata: {
        // Exact same JSON object as the one returned by the client_id URL
      },
      // ...
    })
  2. Or you load it asynchronously from the URL:

    typescript
    import { OAuthClient } from '@atproto/oauth-client-browser'
    
    const client = await BrowserOAuthClient.load({
      clientId: 'https://my-app.com/client-metadata.json',
      // ...
    })

If performances are important to you, it is recommended to burn the metadata
into the script. Server side rendering techniques can also be used to inject the
metadata into the script at runtime.

Handle Resolver

Whenever your application initiates an OAuth flow, it will start to resolve
the (user provider) APTROTO handle of the user. This is typically done though a
DNS request. However, because DNS resolution is not available in the browser, a
backend service must be provided.

[!CAUTION]

Using Bluesky-hosted services for handle resolution (eg, the bsky.social
endpoint) will leak both user IP addresses and handle identifiers to Bluesky,
a third party. While Bluesky has a declared privacy policy, both developers
and users of applications need to be informed and aware of the privacy
implications of this arrangement. Application developers are encouraged to
improve user privacy by operating their own handle resolution service when
possible. If you are a PDS self-hoster, you can use your PDS's URL for
handleResolver.

If a string or URL object is used as handleResolver, the library will
expect this value to be the URL of a service running the
com.atproto.identity.resolveHandle XRPC Lexicon method.

[!TIP]

If you host your own PDS, you can use its URL as a handle resolver.

typescript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: 'https://my-pds.example.com',
  // ...
})

Alternatively, if a "DNS over HTTPS" (DoH) service is available, it can be used
to resolve the handle. In this case, the handleResolver should be initialized
with a AtprotoDohHandleResolver instance:

typescript
import {
  BrowserOAuthClient,
  AtprotoDohHandleResolver,
} from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: new AtprotoDohHandleResolver('https://my-doh.example.com'),
  // ...
})

Other configuration options

In addition to Client Metadata and Handle
Resolver
, the BrowserOAuthClient constructor accepts the
following optional configuration options:

  • fetch: A custom wrapper around the fetch function. This can be useful to
    add custom headers, logging, or to use a different fetch implementation.
    Defaults to window.fetch.

  • responseMode: query or fragment. Determines how the authorization
    response is returned to the client. Defaults to fragment.

  • plcDirectoryUrl: The URL of the PLC directory. This will typically not be
    needed unless you run an entire atproto stack locally. Defaults to
    https://plc.directory.

Usage

Once the client is set up, it can be used to initiate & manage OAuth sessions.

Initializing the client

The client will manage the sessions for you. In order to do so, it must first
initialize itself. Note that this operation must be performed once (and only
once
) whenever the web app is loaded.

typescript
const result: undefined | { session: OAuthSession; state?: string } =
  await client.init()

if (result) {
  const { session, state } = result
  if (state != null) {
    console.log(
      `${session.sub} was successfully authenticated (state: ${state})`,
    )
  } else {
    console.log(`${session.sub} was restored (last active session)`)
  }
}

The return value can be used to determine if the client was able to restore the
last used session (session is defined) or if the current navigation is the
result of an authorization redirect (both session and state are defined).

Initiating an OAuth flow

In order to initiate an OAuth flow, we must first determine which PDS the
authentication flow will be initiated from. This means that the user must
provide one of the following information:

  • The user's handle
  • The user's DID
  • A PDS/Entryway URL

Using that information, the OAuthClient will resolve all the needed information
to initiate the OAuth flow, and redirect the user to the OAuth server.

typescript
try {
  await client.signIn('my.handle.com', {
    state: 'some value needed later',
    prompt: 'none', // Attempt to sign in without user interaction (SSO)
    ui_locales: 'fr-CA fr en', // Only supported by some OAuth servers (requires OpenID Connect support + i18n support)
    signal: new AbortController().signal, // Optional, allows to cancel the sign in (and destroy the pending authorization, for better security)
  })

  console.log('Never executed')
} catch (err) {
  console.log('The user aborted the authorization process by navigating "back"')
}

The returned promise will never resolve (because the user will be redirected to
the OAuth server). The promise will reject if the user cancels the sign in
(using an AbortSignal), or if the user navigates back from the OAuth server
(because of browser's back-forward cache).

Handling the OAuth response

When the user is redirected back to the application, the OAuth response will be
available in the URL. The BrowserOAuthClient will automatically detect the
response and handle it when client.init() is called. Alternatively, the
application can manually handle the response using the
client.callback(urlQueryParams) method.

Restoring a session

The client keeps track of all the sessions that it manages through an internal
store. Regardless of the session that was returned from the client.init()
call, any other session can be loaded using the client.restore() method. This
method will throw an error if the session is no longer available or if it has
become expired.

ts
const aliceSession = await client.restore('did:plc:alice')
const bobSession = await client.restore('did:plc:bob')

In its current form, the client does not expose methods to list all sessions
in its store. The app will have to keep track of those itself.

Watching for session invalidation

The client will emit events whenever a session becomes unavailable, allowing to
trigger global behaviors (e.g. show the login page).

ts
client.addEventListener(
  'deleted',
  (
    event: CustomEvent<{
      sub: string
      cause: TokenRefreshError | TokenRevokedError | TokenInvalidError
    }>,
  ) => {
    const { sub, cause } = event.detail
    console.error(`Session for ${sub} is no longer available (cause: ${cause})`)
  },
)

Usage with `@atproto/api`

The @atproto/api package provides a way to interact with multiple Bluesky
specific XRPC lexicons (com.atproto, app.bsky, chat.bsky, tools.ozone)
through the Agent interface. The oauthSession returned by the
BrowserOAuthClient can be used to instantiate an Agent instance.

typescript
import { Agent } from '@atproto/api'

const session = await client.restore('did:plc:alice')

const agent = new Agent(session)

await agent.getProfile({ actor: agent.accountDid })

Any refresh of the credentials will happen under the hood, and the new tokens
will be saved in the session store (in the browser's indexed DB).

Advances use-cases

Using in development (localhost)

The OAuth server must be able to fetch the client_metadata object. The best
way to do this if you didn't already deployed your app is to use a tunneling
service like ngrok.

The client_id will then be something like
https://<your-ngrok-id>.ngrok.io/.

There is however a special case for loopback clients. A loopback client is a
client that runs on localhost. In this case, the OAuth server will not be able
to fetch the client_metadata object because localhost is not accessible from
the outside. To work around this, atproto OAuth servers are required to support
this case by providing an hard coded client_metadata object for the client.

This has several restrictions:

  1. There is no way of configuring the client metadata (name, logo, etc.)
  2. The validity of the refresh tokens (if any) will be very limited (typically 1
    day)
  3. Silent-sign-in will not be allowed
  4. Only http://127.0.0.1:<any_port> and http://[::1]:<any_port> can be used
    as origin for your app, and not http://localhost:<any_port>. This
    library will automatically redirect the user to an IP based origin
    (http://127.0.0.1:) when visiting an origin with localhost.

Using a loopback client is only recommended for development purposes. A loopback
client can be instantiated like this:

typescript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: 'https://bsky.social',
  // Only works if the current origin is a loopback address:
  clientMetadata: undefined,
})

If you need to use a special redirect_uris, you can configure them like this:

typescript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: 'https://bsky.social',
  // Note that the origin of the "client_id" URL must be "http://localhost" when
  // using this configuration, regardless of the actual hostname ("127.0.0.1" or
  // "[::1]"), port or pathname. Only the `redirect_uris` must contain the
  // actual url that will be used to redirect the user back to the application.
  clientMetadata: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:8080/callback')}`,
})

Overview (`packages/oauth/oauth-client-expo/README.md`)

Expo Atproto OAuth

This is an Expo client library for Atproto OAuth. It implements the required
native crypto functions for supporting JWTs in React Native and uses the base
OAuthClient interface found in the Atproto repository.

In bare React Native projects

For bare React Native projects, you must ensure that you have installed and configured the expo package
before continuing.

Installation

Once you have satisfied the prerequisites, you can simply install the library with npm install --save @atproto/oauth-client-expo.

Usage

Serve your `oauth-client-metadata.json`

You will need to server an oauth-client-metadata.json from your application's website. An example of this metadata
would look like this:

json
// assets/oauth-client-metadata.json
{
  "client_id": "https://example.com/oauth-client-metadata.json",
  "client_name": "React Native OAuth Client Demo",
  "client_uri": "https://example.com",
  "redirect_uris": ["com.example:/auth/callback"],
  "scope": "atproto repo:* rpc:*?aud=did:web:api.bsky.app#bsky_appview",
  "token_endpoint_auth_method": "none",
  "response_types": ["code"],
  "grant_types": ["authorization_code", "refresh_token"],
  "application_type": "native",
  "dpop_bound_access_tokens": true
}
  • The client_id should be the same URL as where you are serving your
    oauth-client-metadata.json from
  • The client_uri can be the home page of where you are serving your metadata
    from
  • Your redirect_uris should contain a native redirect URI (for ios/android),
    as well as a web redirect URI (for web).
  • native redirect URI must have a custom scheme, which is formatted as the
    reverse of the domain you are serving the metadata from. Since I am serving
    mine from example.com, I use com.example as the scheme. If my domain were
    atproto.expo.dev, I would use dev.expo.atproto. Additionally, the scheme
    must contain only one trailing slash after the :. com.example:// is
    invalid.
  • The application_type must be native

For a real-world example, see Skylight's client metadata.

For more information about client metadata, see the Atproto documentation.

Create a client

Next, you want to create an ExpoOAuthClient. You will need to pass in the same client metadata to the client as you are serving in your oauth-client-metadata.json.

ts
// utils/oauth-client.ts
const clientMetadata = require('../assets/oauth-client-metadata.json')

const client = new ExpoOAuthClient({
  handleResolver: 'https://bsky.social',
  clientMetadata,
})

Sign a user in

Whenever you are ready, you can initiate a sign in attempt for the user using the client using client.signIn(input)

input must be one of the following:

  • A valid Atproto user handle, e.g. hailey.bsky.team or example.com
  • A valid DID, e.g. did:web:example.com or did:plc:oisofpd7lj26yvgiivf3lxsi
  • A valid PDS host, e.g. https://cocoon.example.com or https://bsky.social

[!NOTE] If you wish to allow a user to create an account instead of signing
in, simply use a valid PDS hostname rather than a handle. They will be
presented the option to either Sign In with an existing account, or create a
new one, on the PDS's sign in page.

The response of signIn will be a promise resolving to the following:

ts
| { status: WebBrowserResultType } // See Expo Web Browser documentation
    | { status: 'error'; error: unknown }
    | { status: 'success'; session: OAuthSession }

For example:

ts
try {
  const session = await client.signIn(input ?? '')
  setSession(session)
  const agent = new Agent(session)
  setAgent(agent)
} catch (err) {
  Alert.alert('Error', String(err))
}

Create an `Agent`

To interface with the various Atproto APIs, you will need to create an Agent. You will pass your OAuthSession to the Agent or XrpcClient constructor.

ts
const agent = new Agent(session)
// or
const xrpc = new XrpcClient(session)

Session refreshes will be handled for you for the lifetime of the agent.

Restoring a session

After, for example, closing the application, you will probably need to restore the user's session. You can do this by using the user's DID on the ExpoOAuthClient.

ts
const session = await client.restore('did:plc:oisofpd7lj26yvgiivf3lxsi')
const agent = new Agent(session)

If the session needs to be refreshed, .restore() will automatically do this for you before returning a session (based on the token's expiration date). In order to force a refresh, you can pass in true as the second argument to restore.

ts
const session = await client.restore(
  'did:plc:oisofpd7lj26yvgiivf3lxsi',
  true, // force a refresh, ensuring tokens were not revoked
)

Additional Reading


Overview (`packages/oauth/oauth-client-node/README.md`)

atproto OAuth Client for NodeJS

This package implements all the OAuth features required by ATPROTO (PKCE,
etc.) to run in a NodeJS based environment such as desktop apps built with
Electron or traditional web app backends built with frameworks like Express.

Setup

Client configuration

The client_id is what identifies your application to the OAuth server. It is
used to fetch the client metadata, and to initiate the OAuth flow. The
client_id must be a URL that points to the client metadata.

Your OAuth client metadata should be hosted at a URL that corresponds to the
client_id of your application. This URL should return a JSON object with the
client metadata. The client metadata should be configured according to the
needs of your application, and must respect the ATPROTO.

From a backend service

The client_metadata object will typically be built by the backend at startup.

text
/* Detailed source-code truncated for AI context efficiency. */

From a native application

This applies to mobile apps, desktop apps, etc. based on NodeJS (e.g. Electron).

The client metadata must be hosted on an internet-accessible URL owned by you.
The client metadata will typically contain:

json
{
  "client_id": "https://my-app.com/client-metadata.json",
  "client_name": "My App",
  "client_uri": "https://my-app.com",
  "logo_uri": "https://my-app.com/logo.png",
  "tos_uri": "https://my-app.com/tos",
  "policy_uri": "https://my-app.com/policy",
  "redirect_uris": ["https://my-app.com/atproto-oauth-callback"],
  "scope": "atproto",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "application_type": "native",
  "token_endpoint_auth_method": "none",
  "dpop_bound_access_tokens": true
}

Instead of hard-coding the client metadata in your app, you can fetch it when
the app starts:

ts
import { NodeOAuthClient } from '@atproto/oauth-client-node'

const client = await NodeOAuthClient.fromClientId({
  clientId: 'https://my-app.com/client-metadata.json',

  stateStore: {
    async set(key: string, internalState: NodeSavedState): Promise<void> {},
    async get(key: string): Promise<NodeSavedState | undefined> {},
    async del(key: string): Promise<void> {},
  },

  sessionStore: {
    async set(sub: string, session: Session): Promise<void> {},
    async get(sub: string): Promise<Session | undefined> {},
    async del(sub: string): Promise<void> {},
  },

  // A lock to prevent concurrent access to the session store. Optional if only one instance is running.
  requestLock,
})

[!NOTE]

There is no keyset in this instance. This is due to the fact that app
clients cannot safely store a private key. The token_endpoint_auth_method is
set to none in the client metadata, which means that the client will not be
authenticating itself to the token endpoint. This will cause sessions to have
a shorter lifetime. You can circumvent this by providing a "BFF" (Backend for
Frontend) that will perform an authenticated OAuth flow and use a session id
based mechanism to authenticate the client.

Common configuration options

The OAuthClient and OAuthAgent classes will manage and refresh OAuth tokens
transparently. They are also responsible to properly format the HTTP requests
payload, using DPoP, and transparently retrying requests when the access token
expires.

For this to work, the client must be configured with the following options:

`sessionStore`

A simple key-value store to save the OAuth session data. This is used to save
the access token, refresh token, and other session data.

ts
const sessionStore: NodeSavedSessionStore = {
  async set(sub: string, sessionData: NodeSavedSession) {
    // Insert or update the session data in your database
    await saveSessionDataToDb(sub, sessionData)
  },

  async get(sub: string) {
    // Retrieve the session data from your database
    const sessionData = await getSessionDataFromDb(sub)
    if (!sessionData) return undefined

    return sessionData
  },

  async del(sub: string) {
    // Delete the session data from your database
    await deleteSessionDataFromDb(sub)
  },
}

`stateStore`

A simple key-value store to save the state of the OAuth
authorization flow. This is used to prevent CSRF attacks.

The implementation of the StateStore is similar to the
sessionStore.

ts
interface NodeSavedStateStore {
  set: (key: string, internalState: NodeSavedState) => Promise<void>
  get: (key: string) => Promise<NodeSavedState | undefined>
  del: (key: string) => Promise<void>
}

One notable exception is that state store items can (and should) be deleted
after a short period of time (one hour should be more than enough).

`requestLock`

When multiple instances of the client are running, this lock will prevent
concurrent refreshes of the same session. If the lock fails to be acquired an
error should be thrown.

Here is an example implementation based on redlock:

ts
import { RuntimeLock } from '@atproto/oauth-client-node'
import Redis from 'ioredis'
import Redlock from 'redlock'

const redisClients = new Redis()
const redlock = new Redlock(redisClients)

const requestLock: RuntimeLock = async (key, fn) => {
  // 30 seconds should be enough. Since we will be using one lock per user id
  // we can be quite liberal with the lock duration here.
  const lock = await redlock.lock(key, 45e3)
  try {
    return await fn()
  } finally {
    await redlock.unlock(lock)
  }
}

Usage with `@atproto/api`

@atproto/oauth-client-* packages all return an ApiClient instance upon
successful authentication. This instance can be used to make authenticated
requests using all the ApiClient methods defined in [API] (non exhaustive
list of examples below). Any refresh of the credentials will happen under the
hood, and the new tokens will be saved in the session store.

text
/* Detailed source-code truncated for AI context efficiency. */

Advances use-cases

Listening for session updates and deletion

The OAuthClient will emit events whenever a session is updated or deleted.

ts
import {
  Session,
  TokenRefreshError,
  TokenRevokedError,
} from '@atproto/oauth-client-node'

client.addEventListener('updated', (event: CustomEvent<Session>) => {
  console.log('Refreshed tokens were saved in the store:', event.detail)
})

client.addEventListener(
  'deleted',
  (
    event: CustomEvent<{
      sub: string
      cause: TokenRefreshError | TokenRevokedError | unknown
    }>,
  ) => {
    console.log('Session was deleted from the session store:', event.detail)

    const { cause } = event.detail

    if (cause instanceof TokenRefreshError) {
      // - refresh_token unavailable or expired
      // - oauth response error (`cause.cause instanceof OAuthResponseError`)
      // - session data does not match expected values returned by the OAuth server
    } else if (cause instanceof TokenRevokedError) {
      // Session was revoked through:
      // - session.signOut()
      // - client.revoke(sub)
    } else {
      // An unexpected error occurred, causing the session to be deleted
    }
  },
)

Silent Sign-In

Using silent sign-in requires to handle retries on the callback endpoint.

text
/* Detailed source-code truncated for AI context efficiency. */

Overview (`packages/oauth/oauth-client/README.md`)

@atproto/oauth-client: atproto flavoured OAuth client

Core library for implementing atproto OAuth clients.

For a browser specific implementation, see @atproto/oauth-client-browser.
For a node specific implementation, see
@atproto/oauth-client-node.

Usage

Configuration

text
/* Detailed source-code truncated for AI context efficiency. */

Authentication

ts
const url = await client.authorize('foo.bsky.team', {
  state: '434321',
  prompt: 'consent',
  scope: 'email',
  ui_locales: 'fr',
})

Make user visit url. Then, once it was redirected to the callback URI, perform the following:

ts
// Parse the query params from the callback URI
const params = new URLSearchParams('code=...&state=...')

// Process the callback using the OAuth client
const result = await client.callback(params)

// Verify the state (e.g. to link to an internal user)
result.state === '434321' // true

const oauthSession = result.session

The sign-in process results in an OAuthSession instance that can be used to make
authenticated requests to the resource server. This instance will automatically
refresh the credentials when needed.

Making authenticated requests

The OAuthSession instance obtained after signing in can be used to make
authenticated requests to the user's PDS. There are two main use-cases:

  1. Making authenticated request to Bluesky's AppView in order to fetch and
    manipulate data from the app.bsky lexicon.

  2. Making authenticated request to your own AppView, in order to fetch and
    manipulate data from your own lexicon.

Making authenticated requests to Bluesky's AppView

The @atproto/oauth-client package provides a OAuthSession class that can be
used to make authenticated requests to Bluesky's AppView. This can be achieved
by constructing an Agent (from @atproto/api) instance using the
OAuthSession instance.

ts
import { Agent } from '@atproto/api'

const agent = new Agent(oauthSession)

// Make an authenticated request to the server. New credentials will be
// automatically fetched if needed (causing sessionStore.set() to be called).
await agent.post({
  text: 'Hello, world!',
})

// revoke credentials on the server (causing sessionStore.del() to be called)
await agent.signOut()

Making authenticated requests to your own AppView

The OAuthSession instance obtained after signing in can be used to instantiate
the XrpcClient class from the @atproto/xrpc package.

ts
import { Lexicons } from '@atproto/lexicon'
import { OAuthClient } from '@atproto/oauth-client' // or "@atproto/oauth-client-browser" or "@atproto/oauth-client-node"
import { XrpcClient } from '@atproto/xrpc'

// Define your lexicons
const myLexicon = new Lexicons([
  {
    lexicon: 1,
    id: 'com.example.query',
    defs: {
      main: {
        // ...
      },
    },
  },
])

// Describe your app's oauth client
const oauthClient = new OAuthClient({
  // ...
})

// Authenticate the user
const oauthSession = await oauthClient.restore('did:plc:123')

// Instantiate a client using the `oauthSession` as fetch handler object
const client = new XrpcClient(oauthSession, myLexicon)

// Make authenticated calls
const response = await client.call('com.example.query')

Note that the user's PDS might not know about your lexicon, or what to do with
those calls (PDS' are only mandated to implement the com.atproto lexicon). In
order to process your calls, you need to have a backend that will process those
calls. You can then instruct your PDS to forward those calls to your backend.

ts
const response = await client.call(
  'com.example.query',
  {
    // Params
  },
  {
    headers: {
      // The PDS will proxy calls to the specified service in did:plc:xyz's did document.
      // These calls will be authenticated using "service auth", a single use JWT Bearer token, signed with the logged-in user's private key.
      'atproto-proxy': 'did:plc:xyz#serviceId',
    },
  },
)

You can also instantiate the XrpcClient class with a custom fetch function
that will provide the atproto-proxy header on all calls:

ts
const boundClient = new XrpcClient((url, init) => {
  const headers = new Headers(init?.headers)

  // Add the atproto-proxy header if it is not already present
  if (!headers.has('atproto-proxy')) {
    headers.set('atproto-proxy', 'did:plc:xyz#serviceId')
  }

  return oauthSession.fetchHandler(url, { ...init, headers })
}, myLexicon)

// No need to specify the atproto-proxy header anymore
const response = await boundClient.call('com.example.query')

[!NOTE]

Proxying every call through the PDS is not recommended for performance
reasons, as it will increase the latency of readonly calls to your lexicon.
Doing so will also prevent your backend from being able to anticipate writes
on the network. Indeed, write calls will be sent to the PDS, which will then
propagate them on the network through a relay (a.k.a. "firehose"). This will
introduce a delay between the time the write is made and the time it is
processed by your backend.

In order to avoid those issues, it is recommended that you implement your
backend using a backend-for-frontend pattern. This backend will be responsible
for processing the calls made by the client, and will be able to anticipate
writes on the network.

Read more about the backend-for-frontend pattern in the atproto
documentation website.

Advances use-cases

Listening for session updates and deletion

The OAuthClient will emit events whenever a session is updated or deleted.

ts
import {
  Session,
  TokenRefreshError,
  TokenRevokedError,
} from '@atproto/oauth-client'

client.addEventListener('updated', (event: CustomEvent<Session>) => {
  console.log('Refreshed tokens were saved in the store:', event.detail)
})

client.addEventListener(
  'deleted',
  (
    event: CustomEvent<{
      sub: string
      cause: TokenRefreshError | TokenRevokedError | unknown
    }>,
  ) => {
    console.log('Session was deleted from the session store:', event.detail)

    const { cause } = event.detail

    if (cause instanceof TokenRefreshError) {
      // - refresh_token unavailable or expired
      // - oauth response error (`cause.cause instanceof OAuthResponseError`)
      // - session data does not match expected values returned by the OAuth server
    } else if (cause instanceof TokenRevokedError) {
      // Session was revoked through:
      // - agent.signOut()
      // - client.revoke(sub)
    } else {
      // An unexpected error occurred, causing the session to be deleted
    }
  },
)

Force user to re-authenticate

ts
const url = await client.authorize(handle, {
  prompt: 'login',
  state,
})

or

ts
const url = await client.authorize(handle, {
  state,
})

Silent Sign-In

Using silent sign-in requires to handle retries on the callback endpoint.

ts
async function createLoginUrl(handle: string, state?: string): string {
  return client.authorize(handle, {
    state,
    // Use "prompt=none" to attempt silent sign-in
    prompt: 'none',
  })
}

async function handleCallback(params: URLSearchParams) {
  try {
    return await client.callback(params)
  } catch (err) {
    // Silent sign-in failed, retry without prompt=none
    if (
      err instanceof OAuthCallbackError &&
      ['login_required', 'consent_required'].includes(err.params.get('error'))
    ) {
      // Do *not* use prompt=none when retrying (to avoid infinite redirects)
      const url = await client.authorize(handle, { state: err.state })

      // Allow calling code to catch the error and redirect the user to the new URL
      return new MyLoginRequiredError(url)
    }

    throw err
  }
}

Overview (`packages/oauth/oauth-provider-ui/README.md`)

@atproto/oauth-provider-ui

The sign-in, sign-up, consent and account-management screens served by a PDS
during OAuth. React 19, TanStack Router, Tailwind 4, shadcn on Base UI, Lingui.

The design decisions behind these screens are in
DesignPrinciples.md; the engineering constraints
are in CLAUDE.md.

Running the mock

sh
pnpm install
pnpm run i18n:compile   # once, or after changing any string
pnpm dev:ui             # http://localhost:5174

Then open:

  • /authorization-page.html — the OAuth flow: picker, sign-in, 2FA,
    password reset, sign-up, consent, redirect.
  • /account-page.html — the account manager, every path under /account.
  • /error-page.html and /cookie-error-page.html.

Everything runs against src/mock-api.ts; no PDS, no Docker. Sign in as any
listed account with any password. matthieu.bsky.social asks for a 2FA code
(AAAAA-AAAAA), alice.test is deactivated.

Checking a change

sh
pnpm exec tsc --build tsconfig.json
pnpm test
pnpm run i18n          # then check the msgid diff and fill every locale

Run eslint from the repository root, not from this package.


Overview (`packages/oauth/oauth-types/README.md`)

@atproto/oauth-types

This library exposes utilities for typing and validating OAuth related data structures.


Overview (`packages/bsky/src/feature-gates/README.md`)

Feature Gates

A thin wrapper around GrowthBook for feature flag
evaluation and experiment tracking.

Usage

Feature gates are accessible on HydrationCtx as features. A default "scope"
is defined in Hydrator.createContext, which is called by every request
handler. The default scope supplies anonymous deviceId and sessionId
identifiers for targeting unauthenticated users.

Feature gates can be checked via the following methods.

typescript
// check a single gate
const enabled = ctx.features.checkGate(
  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,
)

// check multiple gates
const gates = ctx.features.checkGates([
  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,
  ctx.features.Gate.SomeOtherGate,
])
const enabled = gates.get(
  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,
)

User Context

To accurately gate features by user, we need additional context about that user.
For most use cases, we get this data from within the request handlers.

Here, the features client will be scoped to the current user, allowing for
accurate gate checks throughout the request lifecycle.

typescript
const features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(
  ctx.featureGatesClient.parseUserContextFromHandler({
    viewer,
    req,
  }),
)

const hydrateCtx = await ctx.hydrator.createContext({
  labelers,
  viewer,
  features,
})

[!NOTE]
Although ctx.featureGatesClient also has a checkGates method, the
intention is to use the ScopedFeatureGatesClient returned by scope() for
gate checks within the application.

You can also pass the user context directly to checkGate or checkGates. This
overrides any default scope set on the client, allowing for flexibility in cases
where user context is only available at the point of gate evaluation.

typescript
const enabled = ctx.featureGatesClient.checkGate(
  ctx.featureGatesClient.Gate.ImageFeatureEnabled,
  { did: imageAuthor.did },
)

Adding Gates

See gates.ts and add them to the enum. You can optionally prevent metrics from
being emitted for a gate by adding the gate to the IGNORE_METRICS_FOR_GATES
set.

Metrics

Check out the Growthbook docs for more info here. Basically, every feature eval
fires the onFeatureUsage callback. Any feature that is part of an experiment
will also fire the trackingCallback callback.

For some use cases, this can be noisy and/or performance intensive, so make use
of IGNORE_METRICS_FOR_GATES to silence metrics for specific gates.


Overview (`packages/pds/src/scripts/README.md`)

PDS Scripts

This directory includes some low-level administrative scripts primarily meant to help recover from situations of data loss or repository corruption.

These scripts are included in the Docker image. The recommended way to run them is to shell into the PDS container (docker exec -it pds /bin/sh) and run the relevant script using node run-script.js SCRIPT_NAME.

rebuild-repo

Rebuild a repo's MST and sign a new commit based on data stored in the actor's record table. Intended to be used if a repository is corrupted and there are missing MST blocks.

node run-script.js rebuild-repo DID

publish-identity

Publishes an identity event on the PDS's outgoing firehose for the relevant DID. Intended to be used if a user's identity is out of date and a refresh of their identity needs to be pushed through the system.

node run-script.js publish-identity DID
node run-script.js publish-identity DID1 DID2 DID3 ...
node run-script.js publish-identity-file dids.txt (where dids.txt is a \n delimited text file of dids)

rotate-keys

Ensures that an account's signing key in their PLC DID document matches the signing key that the PDS is holidng for them locally. If not, then update their PLC document. Does not work for did:webs. Intended to be used in recovery situations where an accounts' signing key is lost and it needs to be re-generated. This script does not regenerate the key.

node run-script.js rotate-keys DID
node run-script.js rotate-keys DID1 DID2 DID3 ...
node run-script.js rotate-keys-file dids.txt (where dids.txt is a \n delimited text file of dids)
node run-script.js rotate-keys-recovery (to be used after sequencer-recovery script)

sequencer-recovery

Replays the sequencer file on top of actor stores. Creates new actor stores & keys for actors that do not exist but are in the sequencer. Deletes actor stores & entries in the accounts DB when processing an account deletion on the stream. This script is meant to be re-runnable. Though because it processes events in parallel, it is important to be discerning about the cursor you pick up from if you stop & start the script.

Does not rotate signing keys even if it generates them. Signing keys that need to be rotated are stored in a recovery DB and can be actually rotated with the rotate-keys-recovery script.

Intended to be used for recovery from data loss.

node run-script.js sequencer-recovery START_CURSOR CONCURRENCY (both params are optional & default to 0 and 10 respectively)

Failures are also tracked in the recovery DB and can be recovered from with node run-script.js recovery-repair-repos. Which will rebuild repos (a la rebuild-repo) and then play back the events from the sequencer only pertaining to the recovered DIDs.


Overview (`packages/oauth/oauth-client-browser/src/indexed-db/README.md`)

IndexedDB utilities

This is a small wrapper around the IndexedDB API that provides a simple way to
store and retrieve data from an IndexedDB database.

This could be used as a standalone library, but the Bluesky dev team does not
want to maintain it as such. As it is currently only used by the
@atproto/oauth-client-browser package, it is included here.


Overview (`packages/oauth/oauth-provider/src/lib/html/README.md`)

Safe HTML generation and concatenation utility

This library provides a safe way to generate and concatenate HTML strings.

This code could be used as a standalone library, but the Bluesky dev team does
not want to maintain it as such. As it is currently only used by the
@atproto/oauth-provider package, it is included here. Future development
should aim to keep this library independent of the rest of the
@atproto/oauth-provider package, so that it can be extracted and published.


Overview (`packages/oauth/oauth-provider/src/lib/http/README.md`)

utilities for generating middlewares to work with Node's http module or Express / Connect frameworks

This library uses a functional programming style to generate middleware
functions that can be used with Node's http module or Express / Connect
frameworks.

This code could be used as a standalone library, but the Bluesky dev team does
not want to maintain it as such. As it is currently only used by the
@atproto/oauth-provider package, it is included here. Future development
should aim to keep this library independent of the rest of the
@atproto/oauth-provider package, so that it can be extracted and published.


Packages Api Docs Moderation (`packages/api/docs/moderation.md`)

Moderation API

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

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

Configuration

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

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

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

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

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

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

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

This should match the following interfaces:

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

export interface ModerationPrefs {
  adultContentEnabled: boolean
  labels: Record<string, LabelPreference>
  labelers: ModerationPrefsLabeler[]
  mutedWords: AppBskyActorDefs.MutedWord[]
  hiddenPosts: string[]
}

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

You can quickly grab the ModerationPrefs using the agent.getPreferences() method:

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

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

Labelers

Labelers are services that provide moderation labels. Your application will typically have 1+ top-level labelers set with the ability to do "takedowns" on content. This is controlled via this static function, though the default is to use Bluesky's moderation:

typescript
BskyAgent.configure({
  appLabelers: ['did:web:my-labeler.com'],
})

Users 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.

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

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

The label value 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).

Here is how to do this:

typescript
import { AtpAgent } from '@atproto/api'

const agent = new AtpAgent({ service: 'https://example.com' })
// assume `agent` is a signed in session
const prefs = await agent.getPreferences()
const labelDefs = await agent.getLabelDefinitions(prefs)

moderatePost(post, {
  userDid: agent.session.did,
  prefs: prefs.moderationPrefs,
  labelDefs,
})

The `moderate*()` APIs

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

typescript
import {
  moderateProfile,
  moderatePost,
  moderateNotification,
  moderateFeedGen,
  moderateUserList,
  moderateLabeler,
} from '@atproto/api'

Each of these follows the same API signature:

typescript
const res = moderatePost(post, moderationOptions)

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

typescript
res.ui(context) /* =>

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

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

There are multiple UI contexts available:

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

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

typescript
const mod = moderatePost(post, moderationOptions)

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

Sending moderation reports

Any Labeler is capable of receiving moderation reports. As a result, you need to specify which labeler should receive the report. You do this with the Atproto-Proxy header:

typescript
agent
  .withProxy('atproto_labeler', 'did:web:my-labeler.com')
  .createModerationReport({
    reasonType: 'com.atproto.moderation.defs#reasonViolation',
    reason: 'They were being such a jerk to me!',
    subject: { did: 'did:web:bob.com' },
  })

--- METRICS ---