@atproto/lex 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +1287 -0
  2. package/bin/lex +165 -0
  3. package/index.d.ts +6 -0
  4. package/package.json +52 -0
package/README.md ADDED
@@ -0,0 +1,1287 @@
1
+ # @atproto/lex
2
+
3
+ Type-safe Lexicon tooling for creating great API clients.
4
+
5
+ ```bash
6
+ npm install -g @atproto/lex
7
+ lex --help
8
+ ```
9
+
10
+ - Install and manage Lexicon schemas
11
+ - Generate TypeScript client and data validators
12
+ - Handle common tasks like OAuth
13
+
14
+ **What is this?**
15
+
16
+ Working directly with XRPC endpoints requires manually tracking schema definitions, validation data structures, and managing authentication. `@atproto/lex` automates this by:
17
+
18
+ 1. Fetching lexicons from the network and generating TypeScript types
19
+ 2. Providing runtime validation to ensure data matches schemas
20
+ 3. Offering a type-safe client that knows which parameters each endpoint expects
21
+ 4. Support modern patterns like tree-shaking and composition
22
+
23
+ ```typescript
24
+ const profile = await client.call(app.bsky.actor.getProfile, {
25
+ actor: 'atproto.com',
26
+ })
27
+
28
+ await client.create(app.bsky.feed.post, {
29
+ text: 'Hello, world!',
30
+ createdAt: new Date().toISOString(),
31
+ })
32
+
33
+ const posts = await client.list(app.bsky.feed.post, {
34
+ limit: 10,
35
+ repo: 'atproto.com',
36
+ })
37
+
38
+ app.bsky.actor.profile.$validate({
39
+ $type: 'app.bsky.actor.profile',
40
+ displayName: 'Ha'.repeat(32) + '!',
41
+ }) // { success: false, error: Error: grapheme too big (maximum 64) at $.displayName (got 65) }
42
+ ```
43
+
44
+ <!-- START doctoc generated TOC please keep comment here to allow auto update -->
45
+ <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
46
+
47
+ - [Quick Start](#quick-start)
48
+ - [Lexicon Schemas](#lexicon-schemas)
49
+ - [TypeScript Schemas](#typescript-schemas)
50
+ - [Generated Schema Structure](#generated-schema-structure)
51
+ - [Type definitions](#type-definitions)
52
+ - [Validation Helpers](#validation-helpers)
53
+ - [Client API](#client-api)
54
+ - [Creating a Client](#creating-a-client)
55
+ - [Core Methods](#core-methods)
56
+ - [Error Handling](#error-handling)
57
+ - [Authentication Methods](#authentication-methods)
58
+ - [Labeler Configuration](#labeler-configuration)
59
+ - [Low-Level XRPC](#low-level-xrpc)
60
+ - [Advanced Usage](#advanced-usage)
61
+ - [Workflow Integration](#workflow-integration)
62
+ - [Tree-Shaking](#tree-shaking)
63
+ - [Custom Headers](#custom-headers)
64
+ - [Request Options](#request-options)
65
+ - [Actions](#actions)
66
+ - [Building Library-Style APIs with Actions](#building-library-style-apis-with-actions)
67
+ - [License](#license)
68
+
69
+ <!-- END doctoc generated TOC please keep comment here to allow auto update -->
70
+
71
+ ## Quick Start
72
+
73
+ **1. Install Lexicons**
74
+
75
+ Install the Lexicon schemas you need for your application:
76
+
77
+ ```bash
78
+ lex install app.bsky.feed.post app.bsky.feed.like
79
+ ```
80
+
81
+ This creates:
82
+
83
+ - `lexicons.json` - manifest tracking installed Lexicons and their versions (CIDs)
84
+ - `lexicons/` - directory containing the Lexicon JSON files
85
+
86
+ > [!NOTE]
87
+ >
88
+ > The `lex` command might conflict with other binaries intalled on your system.
89
+ > If that happens, you can also run the CLI using `ts-lex`, `pnpm exec lex` or
90
+ > `npx @atproto/lex`.
91
+
92
+ **2. Verify and commit installed Lexicons**
93
+
94
+ Make sure to commit the `lexicons.json` manifest and the `lexicons/` directory containing the JSON files to version control.
95
+
96
+ ```bash
97
+ echo "./src/lexicons" >> .gitignore
98
+ git add lexicons.json lexicons/
99
+ git commit -m "Install Lexicons"
100
+ ```
101
+
102
+ > [!NOTE]
103
+ >
104
+ > The generated TypeScript files don't need to be committed to version control. Instead, they can be pre-built during your project's build step or post-install step. See [Workflow Integration](#workflow-integration) for details.
105
+
106
+ **3. Use in your code**
107
+
108
+ ```typescript
109
+ import { Client } from '@atproto/lex'
110
+ import * as app from './lexicons/app.js'
111
+
112
+ // Create a client instance
113
+ const client = new Client('https://public.api.bsky.app')
114
+
115
+ // Start making requests using generated schemas
116
+ const response = await client.call(app.bsky.actor.getProfile, {
117
+ actor: 'pfrazee.com',
118
+ })
119
+ ```
120
+
121
+ > [!TIP]
122
+ >
123
+ > If you wish to customize the output location, or any other options, you can run the `lex build` command separately. For that purpose, make sure to use the `--no-build` flag when installing lexicons to skip the automatic build step.
124
+
125
+ ## Lexicon Schemas
126
+
127
+ 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.
128
+
129
+ ```bash
130
+ # Install Lexicons and update lexicons.json (default behavior)
131
+ lex install app.bsky.feed.post
132
+
133
+ # Install all Lexicons from lexicons.json manifest
134
+ lex install
135
+
136
+ # Install specific Lexicons without updating manifest
137
+ lex install --no-save app.bsky.feed.post app.bsky.actor.profile
138
+
139
+ # Update (re-fetch) all installed Lexicons to latest versions
140
+ lex install --update
141
+
142
+ # Fetch any missing Lexicons and verify against manifest
143
+ lex install --ci
144
+ ```
145
+
146
+ Options:
147
+
148
+ - `--manifest <path>` - Path to lexicons.json manifest file (default: `./lexicons.json`)
149
+ - `--no-save` - Don't update lexicons.json with installed lexicons (save is enabled by default)
150
+ - `--no-build` - Skip building TypeScript lexicon schema files after installation (build is enabled by default)
151
+ - `--update` - Update all installed lexicons to their latest versions by re-resolving and re-installing them
152
+ - `--ci` - Error if the installed lexicons do not match the CIDs in the lexicons.json manifest
153
+ - `--lexicons <dir>` - Directory containing lexicon JSON files (default: `./lexicons`)
154
+ - `--out <dir>` - Output directory for generated TS files (default: `./src/lexicons`)
155
+
156
+ ## TypeScript Schemas
157
+
158
+ The `lex install` command automatically builds TypeScript schemas after installing Lexicon JSON files. You can also run the build step separately using the `lex build` command. These generated schemas provide type-safe validation, type guards, and builder utilities for working with AT Protocol data structures.
159
+
160
+ ```bash
161
+ lex build --lexicons ./lexicons --out ./src/lexicons
162
+ ```
163
+
164
+ Options:
165
+
166
+ - `--lexicons <dir>` - Directory containing lexicon JSON files (default: `./lexicons`)
167
+ - `--out <dir>` - Output directory for generated TypeScript (default: `./src/lexicons`)
168
+ - `--clear` - Clear output directory before generating
169
+ - `--override` - Override existing files (has no effect with --clear)
170
+ - `--no-pretty` - Don't run prettier on generated files (prettier is enabled by default)
171
+ - `--ignore-errors` - How to handle errors when processing input files
172
+ - `--pure-annotations` - Add `/*#__PURE__*/` annotations for tree-shaking tools. Set this to true if you are using generated lexicons in a library
173
+ - `--exclude <patterns...>` - List of strings or regex patterns to exclude lexicon documents by their IDs
174
+ - `--include <patterns...>` - List of strings or regex patterns to include lexicon documents by their IDs
175
+ - `--lib <package>` - Package name of the library to import the lex schema utility "l" from (default: `@atproto/lex`)
176
+ - `--allowLegacyBlobs` - Allow generating schemas that accept legacy blob references (disabled by default; enabling this might cause compatibility issues with records created a long time ago)
177
+
178
+ ### Generated Schema Structure
179
+
180
+ Each Lexicon generates a TypeScript module with:
181
+
182
+ - **Type definitions** - TypeScript types extracted from the schema
183
+ - **Schema instances** - Runtime validation objects with methods
184
+ - **Exported utilities** - Convenience functions for common operations
185
+
186
+ ### Type definitions
187
+
188
+ You can extract TypeScript types from the generated schemas for use in you application:
189
+
190
+ ```typescript
191
+ import * as app from './lexicons/app.js'
192
+
193
+ // Extract the type for a post record
194
+ type Post = app.bsky.feed.post.Main
195
+
196
+ // Use the extracted types
197
+ const post: Post = {
198
+ $type: 'app.bsky.feed.post',
199
+ text: 'Hello, AT Protocol!',
200
+ createdAt: new Date().toISOString(),
201
+ }
202
+ ```
203
+
204
+ ### Validation Helpers
205
+
206
+ Each schema provides multiple validation methods:
207
+
208
+ #### `$nsid` - Namespace Identifier
209
+
210
+ Returns the NSID of the schema:
211
+
212
+ ```typescript
213
+ import * as app from './lexicons/app.js'
214
+
215
+ console.log(app.bsky.feed.defs.$nsid) // 'app.bsky.feed.defs'
216
+ ```
217
+
218
+ #### `$type` - Type Identifier
219
+
220
+ Returns the `$type` string of the schema (for record and object schemas):
221
+
222
+ ```typescript
223
+ import * as app from './lexicons/app.js'
224
+
225
+ console.log(app.bsky.feed.post.$type) // 'app.bsky.feed.post'
226
+ console.log(app.bsky.actor.defs.profileViewBasic.$type) // 'app.bsky.actor.defs#profileViewBasic'
227
+ ```
228
+
229
+ #### `$check(data)` - Type Guard
230
+
231
+ Returns `true` if data matches the schema, `false` otherwise. Acts as a TypeScript type guard:
232
+
233
+ ```typescript
234
+ import * as app from './lexicons/app.js'
235
+
236
+ const data = {
237
+ $type: 'app.bsky.feed.post',
238
+ text: 'Hello!',
239
+ createdAt: new Date().toISOString(),
240
+ }
241
+
242
+ if (app.bsky.feed.post.$check(data)) {
243
+ // TypeScript knows data is a Post here
244
+ console.log(data.text)
245
+ }
246
+ ```
247
+
248
+ #### `$parse(data)` - Parse and Validate
249
+
250
+ Validates and returns typed data, throwing an error if validation fails:
251
+
252
+ ```typescript
253
+ import * as app from './lexicons/app.js'
254
+
255
+ try {
256
+ const post = app.bsky.feed.post.$main.$parse({
257
+ $type: 'app.bsky.feed.post',
258
+ text: 'Hello!',
259
+ createdAt: new Date().toISOString(),
260
+ })
261
+ // post is now typed and validated
262
+ console.log(post.text)
263
+ } catch (error) {
264
+ console.error('Validation failed:', error)
265
+ }
266
+ ```
267
+
268
+ #### `$validate(data)` - Get Validation Result
269
+
270
+ Returns a detailed validation result object without throwing:
271
+
272
+ ```typescript
273
+ import * as app from './lexicons/app.js'
274
+
275
+ const result = app.bsky.feed.post.$validate({
276
+ $type: 'app.bsky.feed.post',
277
+ text: 'Hello!',
278
+ createdAt: new Date().toISOString(),
279
+ })
280
+
281
+ if (result.success) {
282
+ console.log('Valid post:', result.value)
283
+ } else {
284
+ console.error('Validation failed:', result.error)
285
+ }
286
+ ```
287
+
288
+ #### `$build(data)` - Build with Defaults
289
+
290
+ Creates a valid object by applying defaults for optional fields:
291
+
292
+ ```typescript
293
+ import * as app from './lexicons/app.js'
294
+
295
+ // Build a like record with defaults (and without needing to specify $type)
296
+ const like = app.bsky.feed.like.$build({
297
+ subject: {
298
+ uri: 'at://did:plc:abc/app.bsky.feed.post/123',
299
+ cid: 'bafyrei...',
300
+ },
301
+ createdAt: new Date().toISOString(),
302
+ })
303
+ ```
304
+
305
+ #### `$isTypeOf(data)` - Type Discriminator
306
+
307
+ Discriminates (already validated) data by `$type`, without re-validating. This is especially useful when working with union types:
308
+
309
+ ```typescript
310
+ import { l } from '@atproto/lex'
311
+ import * as app from './lexicons/app.js'
312
+
313
+ declare const data:
314
+ | app.bsky.feed.post.Main
315
+ | app.bsky.feed.like.Main
316
+ | l.TypedObject
317
+
318
+ // Discriminate by $type without re-validating
319
+ if (app.bsky.feed.post.$isTypeOf(data)) {
320
+ // data is a post
321
+ }
322
+ ```
323
+
324
+ ## Client API
325
+
326
+ ### Creating a Client
327
+
328
+ #### Unauthenticated Client
329
+
330
+ Just provide the service URL:
331
+
332
+ ```typescript
333
+ import { Client } from '@atproto/lex'
334
+
335
+ const client = new Client('https://public.api.bsky.app')
336
+ ```
337
+
338
+ #### Authenticated Client with OAuth
339
+
340
+ ```typescript
341
+ import { Client } from '@atproto/lex'
342
+ import { OAuthClient } from '@atproto/oauth-client-node'
343
+
344
+ // Setup OAuth client (see @atproto/oauth-client documentation)
345
+ const oauthClient = new OAuthClient({
346
+ /* ... */
347
+ })
348
+ const session = await oauthClient.restore(userDid)
349
+
350
+ // Create authenticated client
351
+ const client = new Client(session)
352
+ ```
353
+
354
+ For detailed OAuth setup, see the [@atproto/oauth-client](../../../oauth/oauth-client) documentation.
355
+
356
+ #### Creating a Client from Another Client
357
+
358
+ 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.
359
+
360
+ > [!NOTE]
361
+ >
362
+ > 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.
363
+
364
+ ```typescript
365
+ import { Client } from '@atproto/lex'
366
+
367
+ // Base client with authentication
368
+ const baseClient = new Client(session)
369
+
370
+ baseClient.setLabelers(['did:plc:labelerA', 'did:plc:labelerB'])
371
+ baseClient.headers.set('x-app-version', '1.0.0')
372
+
373
+ // Create a new client with additional configuration that will get merged with
374
+ // baseClient's settings on every request.
375
+ const configuredClient = new Client(baseClient, {
376
+ labelers: ['did:plc:labelerC'],
377
+ headers: { 'x-trace-id': 'abc123' },
378
+ })
379
+ ```
380
+
381
+ This pattern is particularly useful when you need to:
382
+
383
+ - Configure labelers after authentication
384
+ - Add application-specific headers
385
+ - Create multiple clients with different configurations from the same session
386
+
387
+ **Example: Configuring labelers after sign-in**
388
+
389
+ ```typescript
390
+ import { Client } from '@atproto/lex'
391
+ import * as app from './lexicons/app.js'
392
+
393
+ async function createBaseClient(session: OAuthSession) {
394
+ // Create base client
395
+ const client = new Client(session, {
396
+ service: 'did:web:api.bsky.app#bsky_appview',
397
+ })
398
+
399
+ // Fetch user preferences
400
+ const { preferences } = await client.call(app.bsky.actor.getPreferences)
401
+
402
+ // Extract labeler preferences
403
+ const labelerPref = preferences.findLast((p) =>
404
+ app.bsky.actor.defs.labelersPref.check(p),
405
+ )
406
+ const labelers = labelerPref?.labelers.map((l) => l.did) ?? []
407
+
408
+ // Configure the client with the user's preferred labelers
409
+ client.setLabelers(labelers)
410
+
411
+ return client
412
+ }
413
+
414
+ // Usage
415
+ const baseClient = await createBaseClient(session)
416
+
417
+ // Create a new client with a different service, but reusing the labelers
418
+ // from the base client.
419
+ const otherClient = new Client(baseClient, {
420
+ service: 'did:web:com.example.other#other_service',
421
+ })
422
+
423
+ // Whenever you update labelers on the base client, the other client will automatically
424
+ // receive the same updates, since they share the same labeler set.
425
+ ```
426
+
427
+ #### Client with Service Proxy (authenticated only)
428
+
429
+ ```typescript
430
+ import { Client } from '@atproto/lex'
431
+
432
+ // Route requests through a specific service
433
+ const client = new Client(session, {
434
+ service: 'did:web:api.bsky.app#bsky_appview',
435
+ })
436
+ ```
437
+
438
+ ### Core Methods
439
+
440
+ #### `client.call()`
441
+
442
+ Call procedures or queries defined in Lexicons.
443
+
444
+ ```typescript
445
+ import * as app from './lexicons/app.js'
446
+
447
+ // Query (GET request)
448
+ const profile = await client.call(app.bsky.actor.getProfile, {
449
+ actor: 'pfrazee.com',
450
+ })
451
+
452
+ // Procedure (POST request)
453
+ const result = await client.call(app.bsky.feed.sendInteractions, {
454
+ interactions: [
455
+ /* ... */
456
+ ],
457
+ })
458
+
459
+ // With options
460
+ const timeline = await client.call(
461
+ app.bsky.feed.getTimeline,
462
+ {
463
+ limit: 50,
464
+ },
465
+ {
466
+ signal: abortSignal,
467
+ headers: { 'custom-header': 'value' },
468
+ },
469
+ )
470
+ ```
471
+
472
+ #### `client.create()`
473
+
474
+ Create a new record.
475
+
476
+ ```typescript
477
+ import * as app from './lexicons/app.js'
478
+
479
+ const result = await client.create(app.bsky.feed.post, {
480
+ text: 'Hello, world!',
481
+ createdAt: new Date().toISOString(),
482
+ })
483
+
484
+ console.log(result.uri) // at://did:plc:...
485
+ console.log(result.cid)
486
+ ```
487
+
488
+ Options:
489
+
490
+ - `rkey` - Custom record key (auto-generated if not provided)
491
+ - `validate` - Validate record against schema before creating
492
+ - `swapCommit` - CID for optimistic concurrency control
493
+
494
+ #### `client.get()`
495
+
496
+ Retrieve a record.
497
+
498
+ ```typescript
499
+ import * as app from './lexicons/app.js'
500
+
501
+ const profile = await client.get(app.bsky.actor.profile)
502
+
503
+ console.log(profile.displayName)
504
+ console.log(profile.description)
505
+ ```
506
+
507
+ For records with non-literal keys:
508
+
509
+ ```typescript
510
+ const post = await client.get(app.bsky.feed.post, {
511
+ rkey: '3jxf7z2k3q2',
512
+ })
513
+ ```
514
+
515
+ #### `client.put()`
516
+
517
+ Update an existing record.
518
+
519
+ ```typescript
520
+ import * as app from './lexicons/app.js'
521
+
522
+ await client.put(app.bsky.actor.profile, {
523
+ displayName: 'New Name',
524
+ description: 'Updated bio',
525
+ })
526
+ ```
527
+
528
+ Options:
529
+
530
+ - `rkey` - Record key (required for non-literal keys)
531
+ - `swapCommit` - Expected repo commit CID
532
+ - `swapRecord` - Expected record CID
533
+
534
+ #### `client.delete()`
535
+
536
+ Delete a record.
537
+
538
+ ```typescript
539
+ import * as app from './lexicons/app.js'
540
+
541
+ await client.delete(app.bsky.feed.post, {
542
+ rkey: '3jxf7z2k3q2',
543
+ })
544
+ ```
545
+
546
+ #### `client.list()`
547
+
548
+ List records in a collection.
549
+
550
+ ```typescript
551
+ import * as app from './lexicons/app.js'
552
+
553
+ const result = await client.list(app.bsky.feed.post, {
554
+ limit: 50,
555
+ reverse: true,
556
+ })
557
+
558
+ for (const record of result.records) {
559
+ console.log(record.uri, record.value.text)
560
+ }
561
+
562
+ // Pagination
563
+ if (result.cursor) {
564
+ const nextPage = await client.list(app.bsky.feed.post, {
565
+ cursor: result.cursor,
566
+ limit: 50,
567
+ })
568
+ }
569
+ ```
570
+
571
+ ### Error Handling
572
+
573
+ 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.
574
+
575
+ #### Safe Methods
576
+
577
+ Each client method has a corresponding "Safe" variant that catches errors and returns them as part of the result type:
578
+
579
+ - `xrpcSafe()` - Safe version of `xrpc()`
580
+ - `createRecordsSafe()` - Safe version of `createRecord()`
581
+ - `deleteRecordsSafe()` - Safe version of `deleteRecord()`
582
+ - `getRecordsSafe()` - Safe version of `getRecord()`
583
+ - `putRecordsSafe()` - Safe version of `putRecord()`
584
+
585
+ #### ResponseFailure Type
586
+
587
+ Safe methods return a union type that includes the success case and all possible failure cases:
588
+
589
+ ```typescript
590
+ import { Client, ResponseFailure } from '@atproto/lex'
591
+ import * as app from './lexicons/app.js'
592
+
593
+ const client = new Client(session)
594
+
595
+ // Using a safe method
596
+ const result = await client.xrpcSafe(com.atproto.identity.resolveHandle, {
597
+ params: { limit: 50 },
598
+ })
599
+
600
+ if (result.success) {
601
+ // Success - result is an XrpcResponse
602
+ console.log(result.body)
603
+ } else {
604
+ // Failure - result is a ResponseFailure, the type depends on the method's error definitions
605
+
606
+ result // ResponseFailure<"HandleNotFound">
607
+
608
+ // Handle error based on type
609
+ if (result.name === 'UnexpectedError') {
610
+ // Network error, invalid response, etc.
611
+ result.error // "unknown" type
612
+ } else if (result.name === 'Unknown') {
613
+ // Server returned a valid XRPC error response with an unknown error type
614
+ result.error // XrpcResponseError<string>
615
+ } else {
616
+ // Declared error from the method's errors list
617
+ result.error // XrpcResponseError<"HandleNotFound">
618
+ }
619
+ }
620
+ ```
621
+
622
+ The `ResponseFailure<M>` type is a union with three possible error types:
623
+
624
+ 1. **Declared errors** - Errors explicitly listed in the method's Lexicon schema will be represented as an `XrpcResponseError<N>` instance:
625
+
626
+ ```typescript
627
+ // XrpcResponseError<N>
628
+ type KnownXrpcResponseFailure<N extends string> = {
629
+ success: false
630
+ name: N
631
+ error: XrpcResponseError<N>
632
+
633
+ // Additional response details
634
+ status: number
635
+ headers: Headers
636
+ encoding: undefined | string
637
+ body: XrpcErrorBody<N>
638
+ }
639
+ ```
640
+
641
+ 2. **Unknown errors** - Server errors not declared in the method's schema:
642
+
643
+ ```typescript
644
+ // XrpcResponseFailure<'Unknown', XrpcResponseError>
645
+ type UnknownXrpcResponseFailure = {
646
+ success: false
647
+ name: 'Unknown'
648
+ error: XrpcResponseError<string>
649
+ }
650
+ ```
651
+
652
+ 3. **Unexpected errors** - Network errors, invalid responses, or other client-side errors:
653
+ ```typescript
654
+ // XrpcResponseFailure<'UnexpectedError', unknown>
655
+ type UnexpectedXrpcResponseFailure = {
656
+ success: false
657
+ name: 'UnexpectedError'
658
+ error: unknown // Could be anything (network error, parsing error, etc.)
659
+ }
660
+ ```
661
+
662
+ ### Authentication Methods
663
+
664
+ #### `client.did`
665
+
666
+ Get the authenticated user's DID.
667
+
668
+ ```typescript
669
+ const did = client.did // Returns Did | undefined
670
+ ```
671
+
672
+ #### `client.assertAuthenticated()`
673
+
674
+ Assert that the client is authenticated (throws if not).
675
+
676
+ ```typescript
677
+ client.assertAuthenticated()
678
+ // After this call, TypeScript knows client.did is defined
679
+ const did = client.did // Type: Did (not undefined)
680
+ ```
681
+
682
+ #### `client.assertDid`
683
+
684
+ Get the authenticated user's DID, asserting that the client is authenticated.
685
+
686
+ ```typescript
687
+ const did = client.assertDid // Type: Did (throws if not authenticated)
688
+ ```
689
+
690
+ 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.
691
+
692
+ ### Labeler Configuration
693
+
694
+ Configure content labelers for moderation.
695
+
696
+ ```typescript
697
+ import { Client } from '@atproto/lex'
698
+
699
+ // Global app-level labelers
700
+ Client.configure({
701
+ appLabelers: ['did:plc:labeler1', 'did:plc:labeler2'],
702
+ })
703
+
704
+ // Client-specific labelers
705
+ const client = new Client(session, {
706
+ labelers: ['did:plc:labeler3'],
707
+ })
708
+
709
+ // Add labelers dynamically
710
+ client.addLabelers(['did:plc:labeler4'])
711
+
712
+ // Replace all labelers
713
+ client.setLabelers(['did:plc:labeler5'])
714
+
715
+ // Clear labelers
716
+ client.clearLabelers()
717
+ ```
718
+
719
+ ### Low-Level XRPC
720
+
721
+ For advanced use cases, use `client.xrpc()` to get the full response (headers, status, body):
722
+
723
+ ```typescript
724
+ import * as app from './lexicons/app.js'
725
+
726
+ const response = await client.xrpc(app.bsky.feed.getTimeline, {
727
+ params: { limit: 50 },
728
+ signal: abortSignal,
729
+ headers: { 'custom-header': 'value' },
730
+ })
731
+
732
+ console.log(response.status)
733
+ console.log(response.headers)
734
+ console.log(response.body)
735
+ ```
736
+
737
+ ## Advanced Usage
738
+
739
+ ### Workflow Integration
740
+
741
+ #### Development Workflow
742
+
743
+ Add these scripts to your `package.json`:
744
+
745
+ ```json
746
+ {
747
+ "scripts": {
748
+ "lex:update": "lex install --update --save",
749
+ "prebuild": "lex install --ci"
750
+ }
751
+ }
752
+ ```
753
+
754
+ This ensures that:
755
+
756
+ 1. Lexicons are installed/verified before every build.
757
+ 2. You can easily update lexicons with `npm run lex:update` or `pnpm lex:update`.
758
+
759
+ ### Tree-Shaking
760
+
761
+ The generated TypeScript is optimized for tree-shaking. Import only what you need:
762
+
763
+ ```typescript
764
+ // Import specific methods
765
+ import { post } from './lexicons/app/bsky/feed/post.js'
766
+ import { getProfile } from './lexicons/app/bsky/actor/getProfile.js'
767
+
768
+ // Or use namespace imports (still tree-shakeable)
769
+ import * as app from './lexicons/app.js'
770
+ ```
771
+
772
+ For library authors, use `--pure-annotations` when building:
773
+
774
+ ```bash
775
+ lex build --pure-annotations
776
+ ```
777
+
778
+ This will make the generated code more tree-shakeable from places that import your library.
779
+
780
+ ### Custom Headers
781
+
782
+ Add custom headers to all requests:
783
+
784
+ ```typescript
785
+ const client = new Client(session, {
786
+ headers: {
787
+ 'x-custom-header': 'value',
788
+ },
789
+ })
790
+ ```
791
+
792
+ ### Request Options
793
+
794
+ All client methods accept options for controlling request behavior. The available options depend on the type of operation.
795
+
796
+ #### Base Call Options
797
+
798
+ All methods support these base options:
799
+
800
+ ```typescript
801
+ type CallOptions = {
802
+ signal?: AbortSignal // Abort the request
803
+ headers?: HeadersInit // Custom request headers
804
+ service?: Service // Override service proxy for this request
805
+ }
806
+ ```
807
+
808
+ #### Query and Procedure Calls
809
+
810
+ When using `.call()` with Query or Procedure schemas:
811
+
812
+ ```typescript
813
+ import * as app from './lexicons/app.js'
814
+
815
+ // Query with parameters
816
+ const timeline = await client.call(
817
+ app.bsky.feed.getTimeline,
818
+ { limit: 50 },
819
+ {
820
+ signal: abortController.signal,
821
+ headers: { 'x-custom': 'value' },
822
+ },
823
+ )
824
+
825
+ // Procedure with body
826
+ const result = await client.call(
827
+ app.bsky.actor.putPreferences,
828
+ { preferences: [...] },
829
+ {
830
+ signal: abortController.signal,
831
+ },
832
+ )
833
+ ```
834
+
835
+ For low-level access with full response data, use `.xrpc()`:
836
+
837
+ ```typescript
838
+ const response = await client.xrpc(app.bsky.feed.getTimeline, {
839
+ params: { limit: 50 },
840
+ signal: abortController.signal,
841
+ headers: { 'x-custom': 'value' },
842
+ skipVerification: false, // Whether to skip response schema validation
843
+ })
844
+
845
+ console.log(response.status) // 200
846
+ console.log(response.headers) // Headers object
847
+ console.log(response.body) // Parsed response body
848
+ ```
849
+
850
+ #### Record Operations (CRUD)
851
+
852
+ Record operations support additional options beyond base `CallOptions`:
853
+
854
+ **Creating Records**
855
+
856
+ ```typescript
857
+ import * as app from './lexicons/app.js'
858
+
859
+ await client.create(
860
+ app.bsky.feed.post,
861
+ {
862
+ text: 'Hello!',
863
+ createdAt: new Date().toISOString(),
864
+ },
865
+ {
866
+ // Base options
867
+ signal: abortController.signal,
868
+ headers: { 'x-custom': 'value' },
869
+
870
+ // Create-specific options
871
+ rkey: 'custom-key', // Custom record key (optional, auto-generated if omitted)
872
+ validate: true, // Validate before creating
873
+ swapCommit: 'bafyrei...', // CID for optimistic concurrency
874
+ },
875
+ )
876
+ ```
877
+
878
+ **Reading Records**
879
+
880
+ ```typescript
881
+ await client.get(app.bsky.actor.profile, {
882
+ // Base options
883
+ signal: abortController.signal,
884
+
885
+ // Get-specific options
886
+ rkey: 'self', // Record key (required for non-literal keys)
887
+ })
888
+ ```
889
+
890
+ **Updating Records**
891
+
892
+ ```typescript
893
+ await client.put(
894
+ app.bsky.actor.profile,
895
+ {
896
+ displayName: 'New Name',
897
+ description: 'Updated bio',
898
+ },
899
+ {
900
+ // Base options
901
+ signal: abortController.signal,
902
+
903
+ // Put-specific options
904
+ rkey: 'self', // Record key
905
+ validate: true, // Validate before updating
906
+ swapCommit: 'bafyrei...', // Expected repo commit CID
907
+ swapRecord: 'bafyrei...', // Expected record CID (for CAS)
908
+ },
909
+ )
910
+ ```
911
+
912
+ **Deleting Records**
913
+
914
+ ```typescript
915
+ await client.delete(app.bsky.feed.post, {
916
+ // Base options
917
+ signal: abortController.signal,
918
+
919
+ // Delete-specific options
920
+ rkey: '3jxf7z2k3q2', // Record key
921
+ swapCommit: 'bafyrei...', // Expected repo commit CID
922
+ swapRecord: 'bafyrei...', // Expected record CID
923
+ })
924
+ ```
925
+
926
+ **Listing Records**
927
+
928
+ ```typescript
929
+ await client.list(app.bsky.feed.post, {
930
+ // Base options
931
+ signal: abortController.signal,
932
+
933
+ // List-specific options
934
+ limit: 50, // Maximum records to return
935
+ cursor: 'abc123', // Pagination cursor
936
+ reverse: true, // Reverse chronological order
937
+ })
938
+ ```
939
+
940
+ ### Actions
941
+
942
+ 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.
943
+
944
+ #### What are Actions?
945
+
946
+ An `Action` is a function with this signature:
947
+
948
+ ```typescript
949
+ type Action<Input, Output> = (
950
+ client: Client,
951
+ input: Input,
952
+ options: CallOptions,
953
+ ) => Output | Promise<Output>
954
+ ```
955
+
956
+ Actions receive:
957
+
958
+ - `client` - The Client instance (to make XRPC calls)
959
+ - `input` - The input data for the action
960
+ - `options` - Call options (signal, headers)
961
+
962
+ #### Using Actions
963
+
964
+ Actions are called using `client.call()`, the same method used for XRPC queries and procedures:
965
+
966
+ ```typescript
967
+ import { Action, Client } from '@atproto/lex'
968
+ import * as app from './lexicons/app.js'
969
+
970
+ // Define an action
971
+ export const likePost: Action<
972
+ { uri: string; cid: string },
973
+ { uri: string; cid: string }
974
+ > = async (client, { uri, cid }, options) => {
975
+ client.assertAuthenticated()
976
+
977
+ const result = await client.create(
978
+ app.bsky.feed.like,
979
+ {
980
+ subject: { uri, cid },
981
+ createdAt: new Date().toISOString(),
982
+ },
983
+ options,
984
+ )
985
+
986
+ return result
987
+ }
988
+
989
+ // Use the action
990
+ const client = new Client(session)
991
+ const like = await client.call(likePost, {
992
+ uri: 'at://did:plc:abc/app.bsky.feed.post/123',
993
+ cid: 'bafyreiabc...',
994
+ })
995
+ ```
996
+
997
+ #### Composing Multiple Operations
998
+
999
+ Actions excel at combining multiple XRPC calls:
1000
+
1001
+ ```typescript
1002
+ import { Action, Client } from '@atproto/lex'
1003
+ import * as app from './lexicons/app.js'
1004
+
1005
+ type Preference = app.bsky.actor.defs.Preferences[number]
1006
+
1007
+ // Action that reads, modifies, and writes preferences
1008
+ const upsertPreference: Action<Preference, Preference[]> = async (
1009
+ client,
1010
+ newPref,
1011
+ options,
1012
+ ) => {
1013
+ // Read current preferences
1014
+ const { preferences } = await client.call(
1015
+ app.bsky.actor.getPreferences,
1016
+ options,
1017
+ )
1018
+
1019
+ // Update the preference list
1020
+ const updated = [
1021
+ ...preferences.filter((p) => p.$type !== newPref.$type),
1022
+ newPref,
1023
+ ]
1024
+
1025
+ // Save updated preferences
1026
+ await client.call(
1027
+ app.bsky.actor.putPreferences,
1028
+ { preferences: updated },
1029
+ options,
1030
+ )
1031
+
1032
+ return updated
1033
+ }
1034
+
1035
+ // Use it
1036
+ await client.call(
1037
+ upsertPreference,
1038
+ app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
1039
+ )
1040
+ ```
1041
+
1042
+ #### Higher-Order Actions
1043
+
1044
+ Actions can call other actions, enabling powerful composition:
1045
+
1046
+ ```typescript
1047
+ import { Action } from '@atproto/lex'
1048
+ import * as app from './lexicons/app.js'
1049
+
1050
+ type Preference = app.bsky.actor.defs.Preferences[number]
1051
+
1052
+ // Low-level action: update preferences with a function
1053
+ const updatePreferences: Action<
1054
+ (prefs: Preference[]) => Preference[] | false,
1055
+ Preference[]
1056
+ > = async (client, updateFn, options) => {
1057
+ const { preferences } = await client.call(
1058
+ app.bsky.actor.getPreferences,
1059
+ options,
1060
+ )
1061
+
1062
+ const updated = updateFn(preferences)
1063
+ if (updated === false) return preferences
1064
+
1065
+ await client.call(
1066
+ app.bsky.actor.putPreferences,
1067
+ { preferences: updated },
1068
+ options,
1069
+ )
1070
+
1071
+ return updated
1072
+ }
1073
+
1074
+ // Higher-level action: upsert a specific preference
1075
+ const upsertPreference: Action<Preference, Preference[]> = async (
1076
+ client,
1077
+ pref,
1078
+ options,
1079
+ ) => {
1080
+ return updatePreferences(
1081
+ client,
1082
+ (prefs) => [...prefs.filter((p) => p.$type !== pref.$type), pref],
1083
+ options,
1084
+ )
1085
+ }
1086
+
1087
+ // Even higher-level: enable adult content
1088
+ const enableAdultContent: Action<void, Preference[]> = async (
1089
+ client,
1090
+ _,
1091
+ options,
1092
+ ) => {
1093
+ return upsertPreference(
1094
+ client,
1095
+ app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
1096
+ options,
1097
+ )
1098
+ }
1099
+
1100
+ // Use the high-level action
1101
+ await client.call(enableAdultContent)
1102
+ ```
1103
+
1104
+ ### Building Library-Style APIs with Actions
1105
+
1106
+ Actions enable you to create high-level, convenience APIs similar to [@atproto/api](https://www.npmjs.com/package/@atproto/api)'s `Agent` class. Here are patterns for common operations:
1107
+
1108
+ #### Creating Posts
1109
+
1110
+ ```typescript
1111
+ import { Action } from '@atproto/lex'
1112
+ import * as app from './lexicons/app.js'
1113
+
1114
+ type PostInput = Partial<app.bsky.feed.post.Main> &
1115
+ Omit<app.bsky.feed.post.Main, 'createdAt'>
1116
+
1117
+ export const post: Action<PostInput, { uri: string; cid: string }> = async (
1118
+ client,
1119
+ record,
1120
+ options,
1121
+ ) => {
1122
+ return client.create(
1123
+ app.bsky.feed.post,
1124
+ {
1125
+ ...record,
1126
+ createdAt: record.createdAt || new Date().toISOString(),
1127
+ },
1128
+ options,
1129
+ )
1130
+ }
1131
+
1132
+ // Usage
1133
+ await client.call(post, {
1134
+ text: 'Hello, AT Protocol!',
1135
+ langs: ['en'],
1136
+ })
1137
+ ```
1138
+
1139
+ #### Following Users
1140
+
1141
+ ```typescript
1142
+ import { Action } from '@atproto/lex'
1143
+ import { AtUri } from '@atproto/syntax'
1144
+ import * as app from './lexicons/app.js'
1145
+
1146
+ export const follow: Action<
1147
+ { did: string },
1148
+ { uri: string; cid: string }
1149
+ > = async (client, { did }, options) => {
1150
+ return client.create(
1151
+ app.bsky.graph.follow,
1152
+ {
1153
+ subject: did,
1154
+ createdAt: new Date().toISOString(),
1155
+ },
1156
+ options,
1157
+ )
1158
+ }
1159
+
1160
+ export const unfollow: Action<{ followUri: string }, void> = async (
1161
+ client,
1162
+ { followUri },
1163
+ options,
1164
+ ) => {
1165
+ const uri = new AtUri(followUri)
1166
+ await client.delete(app.bsky.graph.follow, {
1167
+ ...options,
1168
+ rkey: uri.rkey,
1169
+ })
1170
+ }
1171
+
1172
+ // Usage
1173
+ const { uri } = await client.call(follow, { did: 'did:plc:abc123' })
1174
+ await client.call(unfollow, { followUri: uri })
1175
+ ```
1176
+
1177
+ #### Updating Profile with Retry Logic
1178
+
1179
+ ```typescript
1180
+ import { Action } from '@atproto/lex'
1181
+ import * as app from './lexicons/app.js'
1182
+ import * as com from './lexicons/com.js'
1183
+
1184
+ type ProfileUpdate = Partial<Omit<app.bsky.actor.profile.Main, '$type'>>
1185
+
1186
+ export const updateProfile: Action<ProfileUpdate, void> = async (
1187
+ client,
1188
+ updates,
1189
+ options,
1190
+ ) => {
1191
+ const maxRetries = 5
1192
+ for (let attempt = 0; ; attempt++) {
1193
+ try {
1194
+ // Get current profile and its CID
1195
+ const res = await client.xrpc(com.atproto.repo.getRecord, {
1196
+ ...options,
1197
+ params: {
1198
+ repo: client.assertDid,
1199
+ collection: 'app.bsky.actor.profile',
1200
+ rkey: 'self',
1201
+ },
1202
+ })
1203
+
1204
+ const current = app.bsky.actor.profile.main.validate(res.body.record)
1205
+
1206
+ // Merge updates with current profile (if valid)
1207
+ const updated = app.bsky.actor.profile.main.build({
1208
+ ...(current.success ? current.value : undefined),
1209
+ ...updates,
1210
+ })
1211
+
1212
+ // Save with optimistic concurrency control
1213
+ await client.put(app.bsky.actor.profile, updated, {
1214
+ ...options,
1215
+ swapRecord: res?.body.cid ?? null,
1216
+ })
1217
+
1218
+ return
1219
+ } catch (error) {
1220
+ // Retry on swap/concurrent modification errors
1221
+ if (
1222
+ error instanceof XrpcRequestFailure &&
1223
+ error.name === 'SwapError' &&
1224
+ attempt < maxRetries - 1
1225
+ ) {
1226
+ continue
1227
+ }
1228
+
1229
+ throw error
1230
+ }
1231
+ }
1232
+ }
1233
+
1234
+ // Usage
1235
+ await client.call(updateProfile, {
1236
+ displayName: 'Alice',
1237
+ description: 'Software engineer',
1238
+ })
1239
+ ```
1240
+
1241
+ #### Packaging Actions as a Library
1242
+
1243
+ Create a collection of actions for your application:
1244
+
1245
+ ```typescript
1246
+ // actions.ts
1247
+ import { Action, Client } from '@atproto/lex'
1248
+ import * as app from './lexicons/app.js'
1249
+
1250
+ export const post: Action</* ... */> = async (client, input, options) => {
1251
+ /* ... */
1252
+ }
1253
+ export const like: Action</* ... */> = async (client, input, options) => {
1254
+ /* ... */
1255
+ }
1256
+ export const follow: Action</* ... */> = async (client, input, options) => {
1257
+ /* ... */
1258
+ }
1259
+ export const updateProfile: Action</* ... */> = async (
1260
+ client,
1261
+ input,
1262
+ options,
1263
+ ) => {
1264
+ /* ... */
1265
+ }
1266
+ ```
1267
+
1268
+ Usage:
1269
+
1270
+ ```typescript
1271
+ import * as actions from './actions.js'
1272
+
1273
+ await client.call(actions.post, { text: 'Hello!' })
1274
+ ```
1275
+
1276
+ #### Best Practices for Actions
1277
+
1278
+ 1. **Type Safety**: Always provide explicit type parameters for `Action<Input, Output>`
1279
+ 2. **Authentication**: Use `client.assertAuthenticated()` when auth is required
1280
+ 3. **Abort Signals**: Check `options.signal?.throwIfAborted()` between long operations
1281
+ 4. **Composition**: Build complex actions from simpler ones
1282
+ 5. **Retries**: Implement retry logic for operations with optimistic concurrency control
1283
+ 6. **Tree-shaking**: Export actions individually to allow tree-shaking (instead of bundling them in a single class)
1284
+
1285
+ ## License
1286
+
1287
+ MIT or Apache2