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:
- Overview and Guides 👈 Best starting point
- Github Discussions 👈 Great place to ask questions
- Protocol Specifications
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/apiclient 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 theapp.bsky.*API endpoints. Running on main network atapi.bsky.app.bsync: Internal synchronization service used by the AppView for cross-service state such as mutes and notifications.ozone: Moderation service implementing thetools.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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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):
{
"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 namedcom/atproto/example.jsonand must contain a valid JSON lexicon document with anidfield 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(CIDbafyreiec24azdo2caoijpefea3x6qfewjvkfu3zcjj3u3lwf3nx3q7nunu). - Relaxation:
altis 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
alton existing records or publishes the relaxation.
- Upstream:
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: Theapp.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 thetools.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 buildCLI, 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
oauth-provider: OAuth 2.1 provider, as used by the PDS.oauth-provider-api: Shared types between the OAuth provider and its UI.oauth-provider-ui: Sign-in and sign-up interface for the OAuth provider.oauth-client: Runtime-agnostic OAuth client base.oauth-client-browser: OAuth client for browsers (WebCrypto + IndexedDB).oauth-client-node: OAuth client for Node.js.oauth-client-expo: OAuth client for Expo applications.oauth-client-browser-example: Example single-page app using atproto OAuth.oauth-types: OAuth typing and validation.oauth-scopes: Manipulation and validation of atproto OAuth scopes.jwk: JSON Web Key abstractions, extended by the implementations below.jwk-jose:jose-backed implementation of@atproto/jwk.jwk-webcrypto: WebCrypto-backed implementation of@atproto/jwk.
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/lexiconstack.
Shared internals
Utility packages published under the @atproto-labs/* scope, all located in ./internal/.
fetch: Isomorphic wrappers around thefetchAPI.fetch-node: SSRF protection forfetch()in Node.js.pipe: Composition of multiple functions into one.did-resolver: DID resolution and verification.handle-resolver: Isomorphic handle-to-DID resolution.handle-resolver-node: Node-specific handle-to-DID resolution.identity-resolver: Full atproto identity resolution.simple-store: Minimal key-value store interfaces and utilities.simple-store-memory: In-memorySimpleStore.simple-store-redis: Redis-backedSimpleStore.xrpc-utils: XRPC server utilities for Node.js.rolldown-plugin-bundle-manifest: Generates a manifest of bundled files from a Rolldown build.
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 ofcommon.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 themake run-dev-envREPL.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:
yarn add @atproto/apiThen in your application:
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:
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).
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:
- @atproto/oauth-client-browser:
for the browser. - @atproto/oauth-client-node: for
Node.js. - @atproto/oauth-client:
Lower level; compatible with most JS engines.
Every @atproto/oauth-client-* implementation has a different way to obtain anOAuthSession instance that can be used to instantiate an Agent (from@atproto/api). Here is an example restoring a previously saved session:
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:
/* 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:
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.
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) // => 1Moderation
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.
/* 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:
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:
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
$ ./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.
$ 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.
$ 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:
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:
const didres = new IdResolver({ plcUrl, fetch: globalThis.fetch })License
This project is dual-licensed under MIT and Apache 2.0 terms:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
Overview (`packages/lex-cli/README.md`)
Lexicon CLI Tool
Command-line tool to generate Lexicon schemas and APIs.
Usage
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 commandExample 1: Generate an api
$ lex gen-api ./api/src ./schemas/com/service/*.json ./schemas/com/another/*.jsonExample 2: Generate a server
$ lex gen-server ./server/src/xrpc ./schemas/com/service/*.json ./schemas/com/another/*.jsonLicense
This project is dual-licensed under MIT and Apache 2.0 terms:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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.
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.
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.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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.
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.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
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') // throwsNameSpaced IDs (NSID)
Syntax specification: https://atproto.com/specs/nsid
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') // => falseAT URI
Syntax specification: https://atproto.com/specs/at-uri-scheme
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
npm install @atproto/tapimport { 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.
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 eventsaddRepos(dids: string[]): Promise<void>- Add repos to track (triggers backfill)removeRepos(dids: string[]): Promise<void>- Stop tracking reposresolveDid(did: string): Promise<DidDocument | null>- Resolve a DID to its DID documentgetRepoInfo(did: string): Promise<RepoInfo>- Get info about a tracked repo
`TapChannel`
WebSocket connection for receiving events. Created via tap.channel().
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.
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:
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:
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`
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`
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:
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
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
import { parseTapEvent } from '@atproto/tap'
const evt = parseTapEvent(jsonData) // validates and returns typed TapEventLicense
MIT
Overview (`packages/ws-client/README.md`)
@atproto/ws-client: WebSocket Client Library
A WebSocket client hardened for long-lived streams
Install
npm install @atproto/ws-clientOverview
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.
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:
// 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.
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."
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.
urlmay 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).shouldReconnectcontrols 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.
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 aHeartbeatTimeoutErrorand a reconnect. The browser's WebSocket API has no ping/pong, so this option is ignored there. - Idle timeout (both platforms):
idleTimeoutMsends the connection with anIdleTimeoutErrorif 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:
maxBufferedBytesis a hard cap. Exceeding it fails the connection with aBufferOverflowError(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:
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:
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)
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:
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:
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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
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
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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 adid:key, used for service-to-service auth. Advertised in the appview'sdid:webdocument.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 toproduction. Otherwise all responses are validated on their way out. There may be other effects of not setting this toproduction, 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 adid:webthat matches the appview's public url, adid:webdocument is served.BSKY_HANDLE_RESOLVE_NAMESERVERS- alternative domain name servers used for handle resolution, comma-separated.BSKY_BLOB_CACHE_LOC- whenBSKY_IMG_URI_ENDPOINTis 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:
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.checkUrlreceives the origin of each request, including redirects, and returns a reason string to block it orundefinedto allow it.unicastLookupchecks DNS results before connecting.isUnicastIpHostname(hostname)checks a literal IP address and returnsundefinedfor a domain name.- This package also exports the helpers from
@atproto-labs/fetchfor 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:
node --import=@atproto-labs/fetch-node/emudhra-preload --import=@atproto/pds/telemetry service.jsOr copy src/emudhra-preload.mjs into your image and load it directly. It needs no other files or packages:
node --import=/opt/mitigations/emudhra-preload.mjs --import=./tracing.mjs service.jsYou 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: falseorNODE_TLS_REJECT_UNAUTHORIZED=0. - Clients must use
tls.connect()after the preload runs. Other TLS libraries, directTLSSocketuse, and incoming client certificates are not covered.--requireruns before--import, so avoid opening connections or saving a reference totls.connectthere. - Workers and forked processes normally inherit the preload. Keep it enabled if you override
execArgvorNODE_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 thefetch() 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:
AtprotoHandleResolverThis implements the official ATPROTO handle resolution
algorithm (and requires a DNS resolver).AppViewHandleResolverThis 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.
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 theAppViewHandleResolver
class.
[!NOTE]
Using theDohHandleResolverrequires a DNS-over-HTTPS server that
supports the DNS-over-HTTPS protocol with "application/dns-json" responses.
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).
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:
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 cacheUsing a custom cache:
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 cacheObserving 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:
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:
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:
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, thehttp.route of the metrics is left as set by the Express instrumentation.
// @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 exposesgetDefaultAtprotoInstrumentations() 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 exposesstatusCodeToString(), which renders a Connect status as the snake_case stringrpc.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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
{
"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
// 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 tobundle-manifest.json.data(boolean): Whether to embed the asset data in the manifest. Defaults tofalse.
Example
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.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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.
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:
An explicit
at://URI passed as an addition — fetched (and verified) from that repo.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'sid.The manifest's
resolvers— an ordered list of local override strategies consulted for
every dependency NSID before the network. Currently onlydirectoryis 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. Adirectoryresolver maps an NSID to/<a>//<c>.json.The network — the always-last fallback: NSID → DNS
_lexicon.<authority>→ DID → signedcom.atproto.lexicon.schemarecord.
[!NOTE]
A future{ "type": "repo", ... }resolver (resolving from specific DIDs rather than DNS
discovery) is planned but not yet implemented.
Symlinks
Lexicons resolved from a local file (sources 2 and 3 above) are symlinked into the outputlexicons/ 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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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.
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:
- Creating sessions with username/password credentials
- Automatic token refresh when access tokens expire
- Session persistence through lifecycle hooks
- Graceful logout with server-side session cleanup
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
npm install @atproto/lex-password-session @atproto/lex-client2. Login and make requests
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:
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:
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:
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 callsrefresh()to ensure the session is valid and tokens are current.
Logout
End the session and notify the server:
await session.logout()After logout:
- The
onDeletedhook 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:
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:
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:
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
onUpdatedis running. Keep this callback fast to avoid delays.
onUpdateFailure
Called when token refresh fails due to transient errors (network issues, server unavailability):
{
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):
{
onDeleted(data) {
console.log('Session ended for:', data.did)
clearPersistedSession(data.did)
redirectToLogin()
}
}onDeleteFailure
Called when logout fails due to transient errors:
{
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:
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:
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:
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
Authorizationheaders to requests - Detecting expired tokens (401 responses or
ExpiredTokenerrors) - 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.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
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:
- MIT license (LICENSE-MIT.txt or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, (LICENSE-APACHE.txt or http://www.apache.org/licenses/LICENSE-2.0)
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
Overview (`packages/lex/lex-server/README.md`)
@atproto/lex-server
Request router for Atproto Lexicon protocols and schemas. See the Changelog for version history.
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/ResponseAPIs (portable across runtimes) - Custom authentication with credential passing
- Graceful shutdown with
AsyncDisposablepattern
[!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:
- Routing requests to type-safe handlers based on Lexicon schemas
- Automatically validating request parameters and bodies
- Providing a flexible authentication system with custom strategies
- Supporting WebSocket subscriptions with backpressure handling
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
- LexRouter
- Queries and Procedures
- Subscriptions
- Authentication
- Error Handling
- Node.js Server
- Advanced Usage
- License
Quick Start
1. Install the package
npm install @atproto/lex-server2. Generate Lexicon schemas
Use @atproto/lex to generate TypeScript schemas from your Lexicon definitions:
lex install app.bsky.actor.getProfile
lex build3. Create a router and add handlers
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
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:
// 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:
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:
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:
// 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:
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:
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:
// 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:
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:
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:
- Is called before parsing the request body
- Receives
params,request, andconnectioninfo - Should throw
LexServerError(orLexServerAuthError) on failure - Returns credentials that are passed to the handler
WWW-Authenticate Headers
Use LexServerAuthError to include WWW-Authenticate headers in error responses:
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 token68valueError Handling
LexServerError
Throw LexServerError to return structured XRPC error responses:
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:
{
"error": "NotFound",
"message": "Profile not found"
}LexServerAuthError
LexServerAuthError extends LexServerError with WWW-Authenticate header support:
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:
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
LexErrorinstances or request aborts.
Node.js Server
The @atproto/lex-server/nodejs subpath provides Node.js-specific utilities.
serve()
Start a server and begin listening:
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:
await using server = await serve(router, { port: 3000 })
// Server is automatically terminated when scope exitsOptions:
type StartServerOptions = {
port?: number
host?: string
gracefulTerminationTimeout?: number // ms to wait for connections to close
}createServer()
Create a server without starting it:
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:
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:
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):
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:
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:
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:
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
// 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// 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' },
})// 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
- Lexicon Schemas
- TypeScript Schemas
- Data Model
- Making simple XRPC Requests
- Client API
- Utilities
- Advanced Usage
- License
Quick Start
1. Install Lexicons
Install the Lexicon schemas you need for your application:
lex install app.bsky.feed.post app.bsky.feed.likeThis creates:
lexicons.json- manifest tracking installed Lexicons and their versions (CIDs)lexicons/- directory containing the Lexicon JSON files
[!NOTE]
The
lexcommand might conflict with other binaries installed on your system.
If that happens, you can also run the CLI usingts-lex,pnpm exec lexornpx @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.
git add lexicons.json lexicons/
git commit -m "Install Lexicons"3. Build TypeScript schemas
Generate TypeScript schemas from the installed Lexicons:
lex buildThis 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 buildcommand. 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:bashecho "/src/lexicons" >> .gitignore
4. Use in your code
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.
# 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 --ciOptions:
--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.
{
"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.
lex build --lexicons ./lexicons --out ./src/lexiconsOptions:
--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$defsnamespace (e.g.com.example.foo.$defs)--no-default-export- Disable generation of adefaultexport of themainschema 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:
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:
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 // trueBuilding 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.
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:
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):
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.
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:
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:
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
$isTypeOfis 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:
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:
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
$parsemethod 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).
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:
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:
// 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:
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:
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:
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.
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:
import { Client } from '@atproto/lex'
const client = new Client('https://public.api.bsky.app')Authenticated Client with OAuth
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:
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)
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().
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— Whentrue, outgoing request bodies are validated against the Lexicon input schema before sending. Useful in development to catch errors early. Default:false.validateResponse— Whentrue, incoming response bodies are validated against the Lexicon output schema. Disabling this can improve performance when you trust the upstream service. Default:true.strictResponseProcessing— Whentrue(default), the client will strictly process responses according to Lex encoding rules, rejecting responses containing invalid Lex data (e.g. floating-point numbers, malformed$bytesor$linkobjects). Whenfalse, the client accepts such responses in a lenient mode: invalid values are returned as-is rather than being rejected or converted,datetimestring 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 withcidandmimeTypeproperties) is accepted. Default:true.
Core Methods
`client.call()`
Call procedures or queries defined in Lexicons.
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.
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.trueforces server-side schema validation,falseexplicitly disables it, andundefined(default) lets the PDS decide (it validates only collections whose schemas it knows)validateRequest- Validate the record locally against schema before submitting the requestswapCommit- CID for optimistic concurrency control
`client.get()`
Retrieve a record.
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:
const post = await client.get(app.bsky.feed.post, {
rkey: '3jxf7z2k3q2',
})`client.put()`
Update an existing record.
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.trueforces server-side schema validation,falseexplicitly disables it, andundefined(default) lets the PDS decide (it validates only collections whose schemas it knows)validateRequest- Validate the record locally against schema before submitting the requestswapCommit- Expected repo commit CIDswapRecord- Expected record CID
`client.delete()`
Delete a record.
import * as app from './lexicons/app.js'
await client.delete(app.bsky.feed.post, {
rkey: '3jxf7z2k3q2',
})`client.list()`
List records in a collection.
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 schemainvalid- Records returned by the server that failed local schema validation (rawLexMapvalues)cursor- Pagination cursor (if more results are available)
`client.applyWrites()`
Perform an atomic batch of create, update, and delete operations in a single request.
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.trueforces server-side schema validation,falseexplicitly disables it, andundefined(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):
/* Detailed source-code truncated for AI context efficiency. */The XrpcFailure<M> type is a union of three error classes:
XrpcResponseError- The server responded with a 4xx/5xx error status code. This is used for all error responses from the upstream server.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.XrpcInternalError- Client-side errors (network failures, timeouts, etc.)
Authentication Methods
`client.did`
Get the authenticated user's DID.
const did = client.did // Returns Did | undefined`client.assertAuthenticated()`
Assert that the client is authenticated (throws if not).
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.
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.
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:
// 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):
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:
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:
/* 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 aDateobject into a validDatetimeString, throwing anInvalidDatetimeErrorif the date cannot be represented as a valid AT Protocol datetime.asDatetimeString(input: string)- Validates and casts an arbitrary string toDatetimeString, throwing anInvalidDatetimeErrorif the string does not conform.isDatetimeString(input)- Type guard that returnstrueif the input is a validDatetimeString.ifDatetimeString(input)- Returns the input as aDatetimeStringif valid, orundefinedotherwise.currentDatetimeString()- Returns the current date and time asDatetimeString.
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:
{
"scripts": {
"update-lexicons": "lex install --update --save",
"postinstall": "lex install --ci",
"prebuild": "lex build",
"build": "# Your build command here"
}
}This ensures that:
- Lexicons are verified against the manifest after every
npm installorpnpm install. - TypeScript schemas are built before your project is built.
- You can easily update lexicons with
npm run update-lexiconsorpnpm 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:
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:
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
$typeofapp.bsky.feed.post(no#main) - XRPC endpoints are exposed as
/xrpc/com.atproto.repo.getRecord(nomain)
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:
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.
Default import (recommended)
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:
// 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:
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:
- 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.
- Any Lexicon document whose path segment is literally
default(for example a hypotheticalcom.example.records.default) would conflict with the generateddefaultexport.
If either of these matters for your use case, you can disable the generation of default exports with the --no-defaultExport flag:
lex build --no-defaultExportSummary
| 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:
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:
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:
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:
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 whenstrictResponseProcessing: falseis 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:
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 actionoptions-ActionOptions(currently just{ signal?: AbortSignal })
Using Actions
Actions are called using client.call(), the same method used for XRPC queries and procedures:
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:
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:
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).
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 toservice: nullandlabelers: 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):
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,
Clientimplemented theAgentinterface, and itsfetchHandlermethod could be used as the agent of anotherClient. This lead to confusing bugs (#5110) where the inner agent (client) would setatproto-proxyandatproto-accept-labelersheaders on requests where the outer client explicitly setservice: nullandlabelers: null. This is no longer the case: aClientcan no longer be used as the agent of anotherClient. 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
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
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
/* Detailed source-code truncated for AI context efficiency. */Packaging Actions as a Library
Create a collection of actions for your application:
// 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:
import * as actions from './actions.js'
await client.call(actions.post, { text: 'Hello!' })Best Practices for Actions
- Type Safety: Always provide explicit type parameters for
Action - Authentication: Use
client.assertAuthenticated()when auth is required - Abort Signals: Check
options.signal?.throwIfAborted()between long operations - Composition: Build complex actions from simpler ones
- Retries: Implement retry logic for operations with optimistic concurrency control
- 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:
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:
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. Theclient_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 theclient_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.
{
// 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:
Either you "burn" the metadata into your application:
typescriptimport { BrowserOAuthClient } from '@atproto/oauth-client-browser' const client = new BrowserOAuthClient({ clientMetadata: { // Exact same JSON object as the one returned by the client_id URL }, // ... })Or you load it asynchronously from the URL:
typescriptimport { 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 forhandleResolver.
If a string or URL object is used as handleResolver, the library will
expect this value to be the URL of a service running thecom.atproto.identity.resolveHandle XRPC Lexicon method.
[!TIP]
If you host your own PDS, you can use its URL as a handle resolver.
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:
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 thefetchfunction. This can be useful to
add custom headers, logging, or to use a different fetch implementation.
Defaults towindow.fetch.responseMode:queryorfragment. Determines how the authorization
response is returned to the client. Defaults tofragment.plcDirectoryUrl: The URL of the PLC directory. This will typically not be
needed unless you run an entire atproto stack locally. Defaults tohttps://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.
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.
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 theclient.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.
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).
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 theBrowserOAuthClient can be used to instantiate an Agent instance.
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 likehttps://<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:
- There is no way of configuring the client metadata (name, logo, etc.)
- The validity of the refresh tokens (if any) will be very limited (typically 1
day) - Silent-sign-in will not be allowed
- Only
http://127.0.0.1:<any_port>andhttp://[::1]:<any_port>can be used
as origin for your app, and nothttp://localhost:<any_port>. This
library will automatically redirect the user to an IP based origin
(http://127.0.0.1:) when visiting an origin withlocalhost.
Using a loopback client is only recommended for development purposes. A loopback
client can be instantiated like this:
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:
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 baseOAuthClient 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:
// 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_idshould be the same URL as where you are serving youroauth-client-metadata.jsonfrom - The
client_urican be the home page of where you are serving your metadata
from - Your
redirect_urisshould 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 fromexample.com, I usecom.exampleas the scheme. If my domain wereatproto.expo.dev, I would usedev.expo.atproto. Additionally, the scheme
must contain only one trailing slash after the:.com.example://is
invalid. - The
application_typemust benative
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.
// 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.teamorexample.com - A valid DID, e.g.
did:web:example.comordid:plc:oisofpd7lj26yvgiivf3lxsi - A valid PDS host, e.g.
https://cocoon.example.comorhttps://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:
| { status: WebBrowserResultType } // See Expo Web Browser documentation
| { status: 'error'; error: unknown }
| { status: 'success'; session: OAuthSession }For example:
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.
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.
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.
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. Theclient_id must be a URL that points to the client metadata.
Your OAuth client metadata should be hosted at a URL that corresponds to theclient_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.
/* 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:
{
"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:
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
keysetin this instance. This is due to the fact that app
clients cannot safely store a private key. Thetoken_endpoint_auth_methodis
set tononein 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.
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 thesessionStore.
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:
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.
/* 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.
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.
/* 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
/* Detailed source-code truncated for AI context efficiency. */Authentication
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:
// 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.sessionThe 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:
Making authenticated request to Bluesky's AppView in order to fetch and
manipulate data from theapp.bskylexicon.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 theOAuthSession instance.
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.
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.
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:
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.
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
const url = await client.authorize(handle, {
prompt: 'login',
state,
})or
const url = await client.authorize(handle, {
state,
})Silent Sign-In
Using silent sign-in requires to handle retries on the callback endpoint.
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
pnpm install
pnpm run i18n:compile # once, or after changing any string
pnpm dev:ui # http://localhost:5174Then 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.htmland/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
pnpm exec tsc --build tsconfig.json
pnpm test
pnpm run i18n # then check the msgid diff and fill every localeRun 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.
// 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.
const features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(
ctx.featureGatesClient.parseUserContextFromHandler({
viewer,
req,
}),
)
const hydrateCtx = await ctx.hydrator.createContext({
labelers,
viewer,
features,
})[!NOTE]
Althoughctx.featureGatesClientalso has acheckGatesmethod, the
intention is to use theScopedFeatureGatesClientreturned byscope()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.
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 DIDnode 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 DIDnode 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:
{
// 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:
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:
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:
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:
{
$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:
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.
import {
moderateProfile,
moderatePost,
moderateNotification,
moderateFeedGen,
moderateUserList,
moderateLabeler,
} from '@atproto/api'Each of these follows the same API signature:
const res = moderatePost(post, moderationOptions)The response object provides an API for figuring out what your UI should do in different contexts.
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:
profileListA profile being listed, eg in search or a follower listprofileViewA profile being viewed directlyavatarThe user's avatar in any contextbannerThe user's banner in any contextdisplayNameThe user's display name in any contextcontentListContent being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etccontentViewContent being viewed direct, eg an opened post, the user list page, the feedgen page, etccontentMediaMedia 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:
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:
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 ---
- Files Extracted: 58
- Estimated Token Budget: ~65551 tokens
- Canonical Reference: https://codewiki.google/github.com/bluesky-social/atproto