@calmlens/sdk 0.9.1 → 0.10.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CalmLens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,7 +10,9 @@ CalmLens provides content moderation and classification for text, images, docume
10
10
  npm install @calmlens/sdk
11
11
  ```
12
12
 
13
- Requires Node.js 22 or later (the minimum its dependencies support), or another runtime with `fetch`, Web Crypto and `AbortSignal.any`, such as Bun or Deno.
13
+ Requires Node.js 22 or later, or another server runtime with `fetch` and Web Crypto, such as Bun, Deno or Cloudflare Workers. The package ships ES modules and CommonJS with type declarations and loads no other packages at runtime: importing only `verifyWebhookSignature` adds about 5 KB (minified) to a bundle, and the whole client about 25 KB.
14
+
15
+ Use it on your server. API keys are secret, so `CalmLensClient` refuses to start in a web page; serve private content to browsers with [signed URLs](#assetscreatesignedurloptions).
14
16
 
15
17
  Every public SDK operation accepts one options object. This keeps call sites stable as optional fields are added.
16
18
 
@@ -71,12 +73,16 @@ new CalmLensClient(options: CalmLensClientOptions)
71
73
  - `projectId` (string, required): Your CalmLens project ID
72
74
  - `baseUrl` (string, optional): API base URL (defaults to `https://api.calmlens.com/v1`)
73
75
  - `cdnBaseUrl` (string, optional): CDN base URL (defaults to `https://cdn.calmlens.com/v1`)
74
- - `timeoutMs` (positive integer, optional): Deadline for each request, including reading the response body (defaults to `120000`)
76
+ - `fetch` (optional): The `fetch` implementation for every request, for example one with a proxy or instrumentation. Defaults to the global `fetch`
77
+ - `uploadFetch`, `cdnFetch` (optional): Override `fetch` for content uploads or for `cdn.serve` only
78
+ - `timeoutMs` (positive integer, optional): Deadline for each API request attempt, including reading the response body (defaults to `120000`). For `cdn.serve` it ends when response headers arrive
79
+ - `uploadTimeoutMs` (positive integer, optional): Deadline for each binary upload attempt. Unset by default, so large files are never cut off; text content uses `timeoutMs`
80
+ - `maxRetries` (integer, optional): Retries for failed requests that are safe to repeat (defaults to `2`, `0` disables them). See [Retries and idempotency](#retries-and-idempotency)
81
+ - `maxResponseBytes` (positive integer, optional): Largest JSON response the client reads (defaults to 32 MiB)
75
82
  - `signal` (AbortSignal, optional): Cancels every request made by this client
76
- - `uploadFetch`, `cdnFetch` (optional): `fetch` implementations for content uploads and CDN requests
77
- - `requestBackend` (optional): An `api-def` request backend for the JSON API, for example `new FetchRequestBackend(customFetch)`
83
+ - `dangerouslyAllowBrowser` (boolean, optional): Lets the client run in a web page. Only for trusted internal tools: anyone who can open the page can read the API key
78
84
 
79
- Options are validated before anything else is set up: a missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl`/`cdnBaseUrl` that is not an absolute `http(s)` URL throws a `CalmLensError`.
85
+ Options are validated before anything else is set up: a missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl`/`cdnBaseUrl` that is not an absolute `http(s)` URL throws a `CalmLensError` with the code `sdk/invalid-config`. The client keeps its API key out of `console.log`, `util.inspect` and `JSON.stringify`.
80
86
 
81
87
  #### Methods
82
88
 
@@ -223,6 +229,14 @@ result.items.forEach(asset => {
223
229
  });
224
230
  ```
225
231
 
232
+ To read every matching asset, iterate instead of paging by hand. Pages are fetched as you go; assets created or deleted meanwhile can shift pages, so one may be skipped or repeated.
233
+
234
+ ```typescript
235
+ for await (const asset of client.assets.iterate({ filter: "status==rejected", pageSize: 100 })) {
236
+ console.log(asset.id);
237
+ }
238
+ ```
239
+
226
240
  ##### assets.listChildren(options)
227
241
 
228
242
  List assets extracted from a website or multimedia document.
@@ -233,6 +247,10 @@ const children = await client.assets.listChildren({
233
247
  pageIndex: 0,
234
248
  pageSize: 20,
235
249
  });
250
+
251
+ for await (const child of client.assets.iterateChildren({ assetId: parentAssetId })) {
252
+ console.log(child.name);
253
+ }
236
254
  ```
237
255
 
238
256
  ##### assets.createSignedUrl(options)
@@ -362,12 +380,16 @@ const response = await client.cdn.serve({
362
380
  });
363
381
  ```
364
382
 
383
+ `serve` returns the `Response` as soon as headers arrive, so you can stream the body (for example `return new Response(response.body, response)`); `timeoutMs` covers only the wait for headers. Non-2xx responses are returned rather than thrown. Pass `init` for extra `fetch` options such as `method: "HEAD"`.
384
+
365
385
  Private CDN content cannot be read directly with API keys. Use a short-lived signed URL for application clients.
366
386
 
367
387
  ##### Multipart upload flow
368
388
 
369
389
  Use `create` with `part` to start multipart uploads, and `uploadPart` for subsequent chunks. `partNumber` defaults to `1` and `isFinal` defaults to `false` when starting an upload.
370
390
 
391
+ The queue mode is chosen when the upload starts and saved by CalmLens: an upload started with `create` is queued when its final part arrives, one started with `moderate` waits for the decision. Later parts can come from another process that only knows the asset ID. Pass `queue` to `uploadPart` to override the saved choice for the final part. Pass `totalSizeInBytes` when you start the upload if you know the file's size, so CalmLens rejects an upload that could never finish before you send the rest.
392
+
371
393
  Image upload sizes use distinct direct and multipart paths:
372
394
 
373
395
  - Direct image uploads must be smaller than `10MB`.
@@ -376,7 +398,7 @@ Image upload sizes use distinct direct and multipart paths:
376
398
 
377
399
  Each non-final multipart part must be at least `5MiB`; only the final part may be smaller. If you send base64, split the original bytes first, then base64-encode each part. Do not split one full base64 string into substrings.
378
400
 
379
- Payloads can be sent as `{ plainText: string }` (text content only), `{ base64: string }`, `{ uint8Array: number[] | Uint8Array }`, `{ buffer: Buffer | ArrayBuffer }`, `{ data: Blob | Response | ReadableStream }`. Use `{ data: file }` for `File` objects. Upload metadata is URL query data and the SDK sends the binary payload as the raw request body without constructing multipart form data. Multipart `ReadableStream` uploads must include the exact `sizeInBytes` so CalmLens can stream each part without buffering it.
401
+ Payloads can be sent as `{ plainText: string }` (text content only), `{ base64: string }`, `{ uint8Array: number[] | Uint8Array }`, `{ buffer: Buffer | ArrayBuffer }`, `{ data: Blob | Response | ReadableStream }`. Use `{ data: file }` for `File` objects. To upload a file from disk in Node.js without reading it into memory, pass `{ data: await openAsBlob(path), type }` (`openAsBlob` from `node:fs`); unlike a stream, a `Blob` can be sent again if a retry is needed. Upload metadata is URL query data and the SDK sends the binary payload as the raw request body without constructing multipart form data. Multipart `ReadableStream` uploads must include the exact `sizeInBytes` so CalmLens can stream each part without buffering it.
380
402
 
381
403
  Use a concrete asset MIME type such as `image/png`, `application/pdf`, or `text/plain`. The SDK rejects ambiguous/container request MIME types such as `application/octet-stream` and `multipart/*`.
382
404
 
@@ -425,37 +447,21 @@ console.log(analyzed.status);
425
447
 
426
448
  ##### verifyWebhookSignature(options)
427
449
 
428
- Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function.
450
+ Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function; see [Webhooks](#webhooks) for `parseWebhookEvent`, which verifies and parses in one step.
429
451
 
430
452
  ```typescript
431
453
  static async verifyWebhookSignature(options: {
432
- payload: string | object; // raw request body (pass the string, not parsed JSON)
433
- signature: string | null | undefined; // x-calmlens-signature header
434
- timestamp: string | number | null | undefined; // x-calmlens-timestamp header
454
+ payload: string | Uint8Array | ArrayBuffer; // the raw request body (a Buffer works too)
455
+ signature?: string | null; // x-calmlens-signature header
456
+ timestamp?: string | number | null; // x-calmlens-timestamp header
457
+ headers?: Headers | Record<string, string | string[] | undefined>; // read when signature/timestamp are omitted
435
458
  secret: string;
436
- toleranceSeconds?: number; // default 300
437
- now?: Date | number; // default Date.now(), for tests
459
+ toleranceSeconds?: number; // default WEBHOOK_SIGNATURE_TOLERANCE_SECONDS (300)
460
+ now?: Date | number; // default Date.now(), for tests
438
461
  }): Promise<boolean>
439
462
  ```
440
463
 
441
- It returns `true` only when the `x-calmlens-signature` header contains a `v1=` HMAC-SHA256 of `"<timestamp>.<raw body>"` (one match in a comma-separated list is enough) and the timestamp is within `toleranceSeconds` of now.
442
-
443
- **Example:**
444
- ```typescript
445
- // In your webhook handler (Fetch API Request)
446
- const payload = await request.text();
447
- const isValid = await CalmLensClient.verifyWebhookSignature({
448
- payload,
449
- signature: request.headers.get('x-calmlens-signature'),
450
- timestamp: request.headers.get('x-calmlens-timestamp'),
451
- secret: WEBHOOK_SECRET,
452
- });
453
-
454
- if (!isValid) {
455
- return new Response('Unauthorized', { status: 401 });
456
- }
457
- const event: WebhookEventPayload = JSON.parse(payload);
458
- ```
464
+ It returns `true` only when the `x-calmlens-signature` header contains a `v1=` HMAC-SHA256 of `"<timestamp>.<raw body>"` (one match in a comma-separated list is enough) and the timestamp is within `toleranceSeconds` of now. Passing parsed JSON as `payload` throws `sdk/invalid-argument`: re-serializing it can change the signed bytes.
459
465
 
460
466
  ## Asset Types
461
467
 
@@ -499,26 +505,74 @@ See the [moderation report guide](https://calmlens.com/docs/guides/moderation-re
499
505
 
500
506
  ## Error Handling
501
507
 
502
- The SDK throws errors for common issues:
508
+ Every failure is a `CalmLensError`, whether the API returned an error or the request never completed. Branch on its stable `code`:
503
509
 
504
510
  ```typescript
511
+ import { CALMLENS_ERROR_CODES, CalmLensError } from "@calmlens/sdk";
512
+
505
513
  try {
506
- const asset = await client.assets.create({
507
- name: "test.jpg",
508
- // Missing required content, URL, or multipart part
509
- });
514
+ await client.assets.retrieve({ assetId });
510
515
  } catch (error) {
511
- console.error(error.message); // "Either file content, URL, or multipart part is required"
516
+ if (!CalmLensError.isCalmLensError(error)) throw error;
517
+ if (error.code === CALMLENS_ERROR_CODES.ASSET_NOT_FOUND) return undefined;
518
+ console.error(error.code, error.status, error.requestId, error.details);
519
+ throw error;
512
520
  }
513
521
  ```
514
522
 
515
- Public API failures are normalized to `CalmLensError`. Use its stable `code` property for control flow and `details` for diagnostics.
523
+ | Property | Meaning |
524
+ | --- | --- |
525
+ | `code` | A stable identifier such as `asset/not-found` or `sdk/timeout`. `CALMLENS_ERROR_CODES` lists the documented ones |
526
+ | `status` | The HTTP status, when the API responded |
527
+ | `requestId` | The `X-Request-Id` response header. Include it when you contact support |
528
+ | `headers` | The response headers, when the API responded |
529
+ | `retryAfter` | Seconds to wait, from the `Retry-After` header (on `429` and `idempotency/request-in-progress`) |
530
+ | `details` | Structured diagnostics from the API, such as validation issues and `traceId` |
531
+ | `cause` | The underlying error, for transport failures |
532
+
533
+ `error.isNotFound()` is true for `404`, and `error.isRetryable()` for transport failures, `429`, `502`, `503`, `504` and `idempotency/request-in-progress`. `instanceof CalmLensError` and `CalmLensError.isCalmLensError(error)` recognise errors from any copy of the SDK, including the ES module and CommonJS builds loaded side by side.
534
+
535
+ Codes the SDK raises itself:
536
+
537
+ | Code | Meaning |
538
+ | --- | --- |
539
+ | `sdk/invalid-argument` | A method argument is missing or invalid, for example an empty `assetId` |
540
+ | `sdk/invalid-config` | The client options are invalid |
541
+ | `sdk/transport-error` | The request could not be completed (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) |
542
+ | `sdk/timeout` | A deadline passed |
543
+ | `sdk/cancelled` | Your `AbortSignal` cancelled the request |
544
+ | `sdk/invalid-response` | A successful response whose body is not the expected JSON |
545
+ | `sdk/http-error` | An HTTP failure without a CalmLens error body, for example from a proxy |
546
+ | `sdk/response-too-large` | A response larger than `maxResponseBytes` |
547
+ | `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent` |
548
+
549
+ Upload content is checked before it is sent with the same codes the API uses, such as `upload/invalid-payload` and `upload/invalid-mime-type`.
550
+
551
+ ## Retries and idempotency
552
+
553
+ The client retries a request when repeating it is safe: reads (`GET`), and creates and uploads, which always carry an `Idempotency-Key` header. It retries failures that happened before any response, `429`, `502`, `503`, `504` and `idempotency/request-in-progress`, up to `maxRetries` times (default 2) with jittered exponential backoff, waiting for `Retry-After` when the API sends one (up to 60 seconds; longer waits are returned to you as the error's `retryAfter`). Other writes, such as `assets.move`, and uploads of a `ReadableStream`, which the first attempt consumed, are never retried.
554
+
555
+ `assets.create`, `assets.moderate`, `text.moderate` and `assets.uploadPart` generate a new key for each call and reuse it for their own retries, so a retry returns the original response instead of creating a second asset. To retry safely across processes or restarts, pass your own key:
556
+
557
+ ```typescript
558
+ await client.text.moderate({
559
+ text: comment.body,
560
+ externalId: `comment-${comment.id}`,
561
+ idempotencyKey: `comment-${comment.id}`,
562
+ });
563
+ ```
564
+
565
+ The API keeps a response for 24 hours. Reusing a key for a different request fails with `idempotency/key-reused`.
566
+
567
+ ## Forward compatibility
568
+
569
+ The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `AssetStatus`, `AssetType` and `AssetVisibility` include `string` for that reason: handle values you don't know (for example, treat an unknown status as "not ready"). Event types and fact keys are typed for the ones this version knows; keep a default branch when you switch on them.
516
570
 
517
571
  ## Testing
518
572
 
519
- `@calmlens/sdk/testing` exports a matching object-parameter `MockCalmLensClient`, in-memory storage implementations, and mock API key helpers for local unit testing without network requests.
573
+ `@calmlens/sdk/testing` exports a matching object-parameter `MockCalmLensClient`, in-memory storage implementations, mock API key helpers, webhook delivery builders and `generateMediaTestCode`, for testing without network requests.
520
574
 
521
- The main `@calmlens/sdk` entry point does not load or export testing helpers to keep production bundles minimal. Keep `/testing` imports in test files.
575
+ The main `@calmlens/sdk` entry point does not load or export testing helpers, to keep production bundles small. Keep `/testing` imports in test files.
522
576
 
523
577
  ```typescript
524
578
  import { MockCalmLensClient, createMockApiKey } from "@calmlens/sdk/testing";
@@ -531,6 +585,8 @@ const client = new MockCalmLensClient({
531
585
 
532
586
  `MockCalmLensClient` accepts the same options as `CalmLensClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, ...) and validates them the same way, plus `mockOptions`. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`).
533
587
 
588
+ It behaves like the API where tests depend on it: errors carry the API's code, HTTP `status`, a `requestId` and `headers`; `assets.list` applies `ordering`, `folderId` and simple `filter` expressions (comparisons joined by `;` and `,`; anything else throws `sdk/invalid-argument` rather than returning wrong results); `assets.search` matches names, IDs, external IDs, descriptions and tags; multipart parts must arrive in order and the queue mode chosen at the start applies to the final part; a reused `idempotencyKey` replays the first result; and `cdn.serve` returns CDN-like responses with JSON error bodies. `client.enqueueEvent({ type, ... })` adds an event that `events.list` and `events.pages` then return.
589
+
534
590
  Every call is recorded in `client.calls` as `{ operation, options }`, where `operation` is the method path (`"text.moderate"`, `"assets.retrieve"`, `"events.list"`, ...) and `options` is the object you passed. `client.callsTo(operation)` filters them. To test failure handling, `client.failNextRequest()` makes the next call throw the same `CalmLensError` the real client throws when the network fails (`sdk/transport-error`); pass `"timeout"` for `sdk/timeout`, or any `Error` to throw it as is, and `{ operation }` to fail only that method. Each queued failure is used once.
535
591
 
536
592
  ```typescript
@@ -543,6 +599,17 @@ await postComment(client, "hello");
543
599
  expect(client.callsTo("text.moderate")[0]?.options).toMatchObject({ text: "hello" });
544
600
  ```
545
601
 
602
+ To test a webhook handler, build a signed delivery the way CalmLens sends it:
603
+
604
+ ```typescript
605
+ import { createWebhookHeaders, toWebhookEventPayload } from "@calmlens/sdk/testing";
606
+
607
+ const event = client.enqueueEvent({ type: "asset.classified", assetId: asset.id });
608
+ const body = JSON.stringify(toWebhookEventPayload(event));
609
+ const headers = await createWebhookHeaders({ secret, body, deliveryId: "delivery-1" });
610
+ const response = await app.request("/api/webhooks/calmlens", { method: "POST", body, headers });
611
+ ```
612
+
546
613
  For `mockOptions`, deterministic timestamps and IDs, storage inspection, and testing recipes, see the [JavaScript SDK Testing Guide](https://calmlens.com/docs/sdk/javascript/testing).
547
614
 
548
615
  ## Webhooks
@@ -574,23 +641,53 @@ Each delivery has these headers (the names are exported as constants):
574
641
  | `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
575
642
  | `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
576
643
 
644
+ Verify the exact raw request body before parsing it; re-serializing parsed JSON can change the signed bytes. `parseWebhookEvent` verifies the delivery and returns the parsed event, or throws `sdk/invalid-webhook-signature` (bad signature or a timestamp more than `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS`, 5 minutes, from your clock, which stops replays) or `sdk/invalid-webhook-payload`. `verifyWebhookSignature` takes the same options and returns a boolean. Both accept the body as a string, `Uint8Array`/`Buffer` or `ArrayBuffer`, and read the two headers from `headers` (a `Headers` object or Node's `req.headers`) when you don't pass `signature` and `timestamp`.
645
+
646
+ The result is typed as `WebhookEventPayload`; narrow it on `type` to get that event type's typed `data.facts` (`EventType` and `EventFacts<Type>` are exported too, and [the webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists every type). Deliveries are retried, so make your handler idempotent by `event.id`.
647
+
648
+ **Fetch API** (Next.js route handlers, Remix, Cloudflare Workers, Bun, Deno):
649
+
577
650
  ```typescript
578
- import {
579
- verifyWebhookSignature,
580
- WEBHOOK_SIGNATURE_HEADER,
581
- WEBHOOK_TIMESTAMP_HEADER,
582
- } from "@calmlens/sdk";
651
+ import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk";
652
+
653
+ export async function POST(request: Request) {
654
+ let event;
655
+ try {
656
+ event = await parseWebhookEvent({
657
+ payload: await request.text(),
658
+ headers: request.headers,
659
+ secret: process.env.CALMLENS_WEBHOOK_SECRET!,
660
+ });
661
+ } catch (error) {
662
+ if (CalmLensError.isCalmLensError(error)) return new Response("Invalid delivery", { status: 401 });
663
+ throw error;
664
+ }
665
+ // Queue the work, keyed by event.id, then respond quickly.
666
+ return new Response(null, { status: 204 });
667
+ }
668
+ ```
669
+
670
+ **Express**: use `express.raw()` for this route so `req.body` is the unparsed `Buffer`:
583
671
 
584
- const payload = await request.text();
585
- const valid = await verifyWebhookSignature({
586
- payload,
587
- signature: request.headers.get(WEBHOOK_SIGNATURE_HEADER),
588
- timestamp: request.headers.get(WEBHOOK_TIMESTAMP_HEADER),
589
- secret: process.env.CALMLENS_WEBHOOK_SECRET!,
672
+ ```typescript
673
+ app.post("/api/webhooks/calmlens", express.raw({ type: "application/json" }), async (req, res) => {
674
+ try {
675
+ const event = await parseWebhookEvent({
676
+ payload: req.body,
677
+ headers: req.headers,
678
+ secret: process.env.CALMLENS_WEBHOOK_SECRET!,
679
+ });
680
+ // ...
681
+ res.sendStatus(204);
682
+ } catch {
683
+ res.sendStatus(401);
684
+ }
590
685
  });
591
686
  ```
592
687
 
593
- Verify the exact raw request body with `verifyWebhookSignature` before parsing it; re-serializing parsed JSON can change the signed bytes. Deliveries more than 5 minutes old (or ahead of your clock) are rejected to prevent replays. The body type is exported as `WebhookEventPayload`; narrow it on `type` to get that event type's typed `data.facts` (`EventType` and `EventFacts<Type>` are exported too, and [the webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists every type).
688
+ **Hono**: `payload: await c.req.text()`, `headers: c.req.raw.headers`.
689
+
690
+ **Fastify**: register a raw body parser for the route, for example `fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => done(null, body))`, then pass `payload: request.body` and `headers: request.headers`.
594
691
 
595
692
  ## Command-line tool
596
693
 
@@ -621,19 +718,18 @@ The same feed is available in code with [`events.list`](#events-feed).
621
718
  - Keep passing the latest `cursor` as `after` until `hasMore` is `false`, then wait before polling again. Store the cursor if you want to resume after a restart.
622
719
  - An unknown or malformed cursor fails with the `CalmLensError` code `events/invalid-cursor`.
623
720
 
721
+ `client.events.pages({ after, limit })` follows the cursor for you: it yields each page until no more events are waiting.
722
+
624
723
  ```typescript
625
724
  let { cursor } = await client.events.list(); // no events, cursor = "now"
626
725
 
627
726
  async function drainEvents() {
628
- let hasMore = true;
629
- while (hasMore) {
630
- const page = await client.events.list({ after: cursor, limit: 100 });
727
+ for await (const page of client.events.pages({ after: cursor, limit: 100 })) {
631
728
  for (const event of page.events) {
632
729
  // narrow on event.type to read its typed facts
633
730
  console.log(event.type, event.facts);
634
731
  }
635
- cursor = page.cursor;
636
- hasMore = page.hasMore;
732
+ cursor = page.cursor; // save it to resume after a restart
637
733
  }
638
734
  }
639
735
 
@@ -642,13 +738,13 @@ setInterval(drainEvents, 5_000);
642
738
 
643
739
  Events carry `facts: { key, value, url? }[]`; webhook deliveries carry the same events reshaped as `{ id, type, createdAt, projectId, data }`.
644
740
 
645
- ## Request budgets and errors
741
+ ## Request budgets
646
742
 
647
- Set `timeoutMs` (default 120000) or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client. Multipart state is shared between scoped clients. Deadlines include response-body reads; authenticated default REST, upload and CDN requests reject redirects. Custom non-Fetch backends own their redirect/cancellation policies.
743
+ Set `timeoutMs` (default 120000), `uploadTimeoutMs`, `maxRetries` or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client; the scoped client shares the original's credentials and lens cache. `timeoutMs` applies to each attempt and includes reading the response body; binary uploads have no deadline unless you set `uploadTimeoutMs`, and `cdn.serve`'s deadline ends when headers arrive, so large transfers are never cut off. Requests never follow redirects, so the API key is never sent to another origin.
648
744
 
649
- `CalmLensError` exposes `code`, optional HTTP `status`, `details` and native `cause`. Every failure to complete a request (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) is `sdk/transport-error`, with the underlying error as `cause`; deadlines are `sdk/timeout`, cancellation `sdk/cancelled`, successful responses whose body is not the expected JSON `sdk/invalid-response`, and HTTP failures without an API error body `sdk/http-error`. `cdn.serve` returns the `Response` once headers arrive, so errors while you read its body come from your `fetch`. API error codes/messages are preserved. Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.
745
+ Requests send `User-Agent: calmlens-js/<version> (<runtime>/<version>)` from server runtimes; `SDK_VERSION` exports the version. Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.
650
746
 
651
- `text.moderate` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.moderate` waits for classification. The old asset analyze method is removed.
747
+ `text.moderate` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.moderate` waits for classification.
652
748
 
653
749
  ## Releasing
654
750