npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@atproto/lex

v0.0.23

Published

Lexicon tooling for AT

Readme

[!IMPORTANT]

This package is currently in preview. The API and features are subject to change before the stable release. See the Changelog for version history.

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

1. Install Lexicons

Install the Lexicon schemas you need for your application:

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

This creates:

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

[!NOTE]

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

2. Verify and commit installed Lexicons

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

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

3. Build TypeScript schemas

Generate TypeScript schemas from the installed Lexicons:

lex build

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

[!TIP]

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

[!NOTE]

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

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

echo "./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 fetches Lexicon schemas from the Atmosphere network 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 --ci

Options:

  • --manifest <path> - 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)

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/lexicons

Options:

  • --lexicons <dir> - Directory containing lexicon JSON files (default: ./lexicons)
  • --out <dir> - Output directory for generated TypeScript (default: ./src/lexicons)
  • --clear - Clear output directory before generating
  • --override - Override existing files (has no effect with --clear)
  • --no-pretty - Don't run prettier on generated files (prettier is enabled by default)
  • --ignore-errors - How to handle errors when processing input files
  • --pure-annotations - Add /*#__PURE__*/ annotations for tree-shaking tools. Set this to true if you are using generated lexicons in a library
  • --exclude <patterns...> - List of strings or regex patterns to exclude lexicon documents by their IDs
  • --include <patterns...> - List of strings or regex patterns to include lexicon documents by their IDs
  • --lib <package> - Package name of the library to import the lex schema utility "l" from (default: @atproto/lex)
  • --allowLegacyBlobs - Allow generating schemas that accept legacy blob references (disabled by default; enable this if you encounter issues while processing records created a long time ago)
  • --importExt <ext> - File extension to use for import statements in generated files (default: .js). Use --importExt "" to generate extension-less imports
  • --fileExt <ext> - File extension to use for generated files (default: .ts)
  • --indexFile - Generate an "index" file that re-exports all root-level namespaces (disabled by default)

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)
}

Building data

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

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.toDatetimeString(new Date()),
})

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

Validation Helpers

Each schema provides multiple validation methods:

$nsid - Namespace Identifier

Returns the NSID of the schema:

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

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

$type - Type Identifier

Returns the $type string of the schema (for record and 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'

$check(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 = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.toDatetimeString(new Date()),
}

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

$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.$main.$parse({
    $type: 'app.bsky.feed.post',
    text: 'Hello!',
    createdAt: l.toDatetimeString(new Date()),
  })
  // post is now typed and validated
  console.log(post.text)
} catch (error) {
  console.error('Validation failed:', error)
}

[!NOTE]

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

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

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

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

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

// 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.toDatetimeString(new Date()),
})

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, and non-raw CIDs are allowed in blob references. 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 })

$build(data) - Build with Defaults

Builds data by adding the $type property and properly types the result. Note that $build() does not perform validation - use $parse() if you need validation:

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"
const like = app.bsky.feed.like.$build({
  subject: {
    uri: 'at://did:plc:abc/app.bsky.feed.post/123',
    cid: 'bafyrei...',
  },
  createdAt: l.toDatetimeString(new Date()),
})

$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
}

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). Additionally, a Client can be used as an Agent for another Client, allowing you to compose headers and configuration across multiple services.

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 — When true, outgoing request bodies are validated against the Lexicon input schema before sending. Useful in development to catch errors early. Default: false.
  • validateResponse — When true, incoming response bodies are validated against the Lexicon output schema. Disabling this can improve performance when you trust the upstream service. Default: true.
  • strictResponseProcessing — When true (default), the client will strictly process responses according to Lex encoding rules, rejecting responses containing invalid Lex data (e.g. floating-point numbers, malformed $bytes or $link objects). When false, the client accepts such responses in a lenient mode: invalid values are returned as-is rather than being rejected or converted, datetime string format checks become more lenient (e.g. datetimes without timezones are accepted) while other string formats remain strict, blob MIME type and size constraints are not enforced, and legacy blob references are coerced into standard BlobRef objects. 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.toDatetimeString(new Date()),
})

console.log(result.uri) // at://did:plc:...
console.log(result.cid)

Options:

  • rkey - Custom record key (auto-generated if not provided)
  • validate - Asks the PDS to validate the record against schema when processing the request
  • validateRequest - Validate the record locally against schema before submitting the request
  • swapCommit - 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 - Validate record against schema before updating (falls back to validateRequest option if not specified)
  • validateRequest - Alternative way to enable validation (used if validate is not specified)
  • swapCommit - Expected repo commit CID
  • swapRecord - 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)
}

// Pagination
if (result.cursor) {
  const nextPage = await client.list(app.bsky.feed.post, {
    cursor: result.cursor,
    limit: 50,
  })
}

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

import {
  Client,
  XrpcResponseError,
  XrpcInvalidResponseError,
  XrpcInternalError,
} from '@atproto/lex'
import * as com from './lexicons/com.js'

const client = new Client(session)

// Using a safe method
const result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {
  params: { handle: 'alice.bsky.social' },
})

if (result.success) {
  // Handle success
  console.log(result.body)
} else {
  // Handle failure - result is an XrpcFailure
  if (result instanceof XrpcResponseError) {
    // The server responded with an error status code (4xx or 5xx).
    // This is used for all error responses, whether or not they have a valid XRPC error payload.

    result.error // string (e.g. "HandleNotFound", "AuthenticationRequired", "UpstreamFailure", etc.)
    result.message // string
    result.response.status // number
    result.response.headers // Headers
    result.payload // undefined | { body: unknown; encoding: string }

    // Coerce to a valid XRPC error payload using toJSON():
    result.toJSON() // { error: string, message?: string }
  } else if (result instanceof XrpcInvalidResponseError) {
    // The response was truly invalid (3xx redirect, malformed JSON, schema mismatch, etc.).
    // This is a more specific error for responses that are not processable.

    result.error // "UpstreamFailure"
    result.message // string
    result.response.status // number
    result.response.headers // Headers
    result.payload // undefined | { body: unknown; encoding: string }
  } else if (result instanceof XrpcInternalError) {
    // Something went wrong on the client side (network error, etc.)
    result.error // "InternalServerError"
    result.message // string
  }

  // All XrpcFailure types have these properties:
  result.shouldRetry() // boolean - whether the error is transient

  if (result.matchesSchemaErrors()) {
    // Check if the error matches a declared error in the schema.
    // TypeScript knows this is a declared error for the method.
    result.error // "HandleNotFound"
  }
}

The XrpcFailure<M> type is a union of three error classes:

  1. XrpcResponseError - The server responded with a 4xx/5xx error status code. This is used for all error responses from the upstream server.

  2. XrpcInvalidResponseError - The upstream server returned a 2xx/3xx that does not comply with XRPC specifications for successful responses. A sub-class, XrpcResponseValidationError, is used for payload schema validation failures specifically.

  3. XrpcInternalError - Client-side errors (network failures, timeouts, etc.)

Authentication Methods

client.did

Get the authenticated user's DID.

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()

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:

import {
  // CID utilities
  parseCid, // Parse CID string (throws on invalid)
  ifCid, // Coerce to Cid or null
  isCid, // Type guard for Cid values

  // Datetime string utilities
  toDatetimeString, // Convert Date to DatetimeString (throws on invalid)
  asDatetimeString, // Cast string to DatetimeString (throws on invalid)
  isDatetimeString, // Type guard for DatetimeString
  ifDatetimeString, // Returns DatetimeString or undefined

  // Blob references
  BlobRef, // { $type: 'blob', ref: Cid, mimeType: string, size: number }
  isBlobRef, // Type guard for BlobRef objects

  // Equality
  lexEquals, // Deep equality (handles CIDs and bytes)

  // String length for Lexicon validation
  graphemeLen, // Count user-perceived characters
  utf8Len, // Count UTF-8 bytes

  // Language tag validation (BCP-47)
  isLanguageString, // Validate language tags (e.g., 'en', 'pt-BR')

  // Low-level JSON encoding helpers
  parseLexLink, // { $link: string } → Cid
  encodeLexLink, // Cid → { $link: string }
  parseLexBytes, // { $bytes: string } → Uint8Array
  encodeLexBytes, // Uint8Array → { $bytes: string }
} from '@atproto/lex'

const cid = parseCid('bafyreiabc...')
graphemeLen('👨‍👩‍👧‍👦') // 1
utf8Len('👨‍👩‍👧‍👦') // 25
isLanguageString('en-US') // true

Datetime Strings

Many AT Protocol records (such as posts, likes, and follows) include a createdAt field that expects a valid DatetimeString. While new Date().toISOString() produces a string that looks like a valid datetime, it is not guaranteed to always conform to the AT Protocol's datetime format requirements (for example, Date objects representing dates before year 0 or after year 9999 will produce non-conforming strings). To ensure correctness and type safety, use the DatetimeString utilities exported from @atproto/lex:

  • toDatetimeString(date: Date) - Converts a Date object into a valid DatetimeString, throwing an InvalidDatetimeError if the date cannot be represented as a valid AT Protocol datetime.
  • asDatetimeString(input: string) - Validates and casts an arbitrary string to DatetimeString, throwing an InvalidDatetimeError if the string does not conform.
  • isDatetimeString(input) - Type guard that returns true if the input is a valid DatetimeString.
  • ifDatetimeString(input) - Returns the input as a DatetimeString if valid, or undefined otherwise.
  • currentDatetimeString() - Returns the current date and time as DatetimeString.
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:

  1. Lexicons are verified against the manifest after every npm install or pnpm install.
  2. TypeScript schemas are built before your project is built.
  3. You can easily update lexicons with npm run update-lexicons or pnpm update-lexicons.

Tree-Shaking

The generated TypeScript is optimized for tree-shaking. Import only what you need:

// Import specific methods
import { post } from './lexicons/app/bsky/feed/post.js'
import { getProfile } from './lexicons/app/bsky/actor/getProfile.js'

// Or use namespace imports (still tree-shakeable)
import * as app from './lexicons/app.js'

For library authors, use --pure-annotations when building:

lex build --pure-annotations

This will make the generated code more easily tree-shakeable from places that import your library.

Blob references

In AT Protocol, binary data (blobs) are referenced using BlobRef, which include metadata like MIME type and size. These references are what allow PDSs to determine which binary data ("files") is referenced by records.

import { BlobRef, isBlobRef } from '@atproto/lex'

const blobRef: BlobRef = {
  $type: 'blob',
  ref: parseCid('bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku'),
  mimeType: 'image/png',
  size: 12345,
}

if (isBlobRef(blobRef)) {
  console.log('Valid BlobRef:', blobRef.mimeType, blobRef.size)
}

[!NOTE]

Historically, references to blobs were represented as simple objects with the following structure:

type LegacyBlobRef = {
  cid: string
  mimeType: string
}

These should no longer be used for new records, but existing records using this format might still be encountered. To handle legacy blob references when validating data, enable the --allowLegacyBlobs flag when generating TypeScript schemas with lex build. You can use isLegacyBlobRef() from @atproto/lex to discriminate legacy blob references.

When using non-strict validation (e.g. $safeParse(data, { strict: false })), legacy blob references are automatically coerced into standard BlobRef objects with size: -1, even without --allowLegacyBlobs.

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: CallOptions,
) => Output | Promise<Output>

Actions receive:

  • client - The Client instance (to make XRPC calls)
  • input - The input data for the action
  • options - Call options (signal)

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.toDatetimeString(new Date()),
    },
    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)

Creating a Client from Another Client

You can create a new Client instance from an existing client. The new client will share the same underlying configuration (authentication, headers, labelers, service proxy), with the ability to override specific settings.

[!NOTE]

When you create a client from another client, the child client inherits the base client's configuration. On every request, the child client merges its own configuration with the base client's current configuration, with the child's settings taking precedence. Changes to the base client's configuration (like baseClient.setLabelers()) will be reflected in child client requests, but changes to child clients do not affect the base client.

import { Client } from '@atproto/lex'

// Base client with authentication
const baseClient = new Client(session)

baseClient.setLabelers(['did:plc:labelerA', 'did:plc:labelerB'])
baseClient.headers.set('x-app-version', '1.0.0')

// Create a new client with additional configuration that will get merged with
// baseClient's settings on every request.
const configuredClient = new Client(baseClient, {
  labelers: ['did:plc:labelerC'],
  headers: { 'x-trace-id': 'abc123' },
})

This pattern is particularly useful when you need to:

  • Configure labelers after authentication
  • Add application-specific headers
  • Create multiple clients with different configurations from the same session

Example: Configuring labelers after sign-in

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

async function createBaseClient(session: OAuthSession) {
  // Create base client
  const client = new Client(session, {
    service: 'did:web:api.bsky.app#bsky_appview',
  })

  // Fetch user preferences
  const { preferences } = await client.call(app.bsky.actor.getPreferences)

  // Extract labeler preferences
  const labelerPref = preferences.findLast((p) =>
    app.bsky.actor.defs.labelersPref.check(p),
  )
  const labelers = labelerPref?.labelers.map((l) => l.did) ?? []

  // Configure the client with the user's preferred labelers
  client.setLabelers(labelers)

  return client
}

// Usage
const baseClient = await createBaseClient(session)

// Create a new client with a different service, but reusing the labelers
// from the base client.
const otherClient = new Client(baseClient, {
  service: 'did:web:com.example.other#other_service',
})

// Whenever you update labelers on the base client, the other client will automatically
// receive the same updates, since they share the same labeler set.

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

import { Action, XrpcResponseError } from '@atproto/lex'
import * as app from './lexicons/app.js'
import * as com from './lexicons/com.js'

type ProfileUpdate = Partial<Omit<app.bsky.actor.profile.Main, '$type'>>

export const updateProfile: Action<ProfileUpdate, void> = async (
  client,
  updates,
  options,
) => {
  const maxRetries = 5
  for (let attempt = 0; ; attempt++) {
    try {
      // Get current profile and its CID
      const res = await client.xrpc(com.atproto.repo.getRecord, {
        ...options,
        params: {
          repo: client.assertDid,
          collection: 'app.bsky.actor.profile',
          rkey: 'self',
        },
      })

      const current = app.bsky.actor.profile.main.validate(res.body.record)

      // Merge updates with current profile (if valid)
      const updated = app.bsky.actor.profile.main.build({
        ...(current.success ? current.value : undefined),
        ...updates,
      })

      // Save with optimistic concurrency control
      await client.put(app.bsky.actor.profile, updated, {
        ...options,
        swapRecord: res?.body.cid ?? null,
      })

      return
    } catch (error) {
      // Retry on swap/concurrent modification errors
      if (
        error instanceof XrpcResponseError &&
        error.name === 'SwapError' &&
        attempt < maxRetries - 1
      ) {
        continue
      }

      throw error
    }
  }
}

// Usage
await client.call(updateProfile, {
  displayName: 'Alice',
  description: 'Software engineer',
})

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

  1. Type Safety: Always provide explicit type parameters for Action<Input, Output>
  2. Authentication: Use client.assertAuthenticated() when auth is required
  3. Abort Signals: Check options.signal?.throwIfAborted() between long operations
  4. Composition: Build complex actions from simpler ones
  5. Retries: Implement retry logic for operations with optimistic concurrency control
  6. Tree-shaking: Export actions individually to allow tree-shaking (instead of bundling them in a single class)

Standard Schema Compatibility

All generated schemas implement the Standard Schema interface (StandardSchemaV1), which means they can be used with any library or framework that supports Standard Schema, such as form validation libraries, API frameworks, and more.

Every Schema instance exposes a ~standard property conforming to the spec:

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.

License

MIT or Apache2