@calmlens/sdk 0.16.3 → 0.18.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 (114) hide show
  1. package/README.md +425 -181
  2. package/bin/calmlens.js +16016 -0
  3. package/bin/package.json +1 -0
  4. package/cjs/BrowserUpload-BT091eOB.d.ts +11 -0
  5. package/cjs/BrowserUpload-CQwXEGFv.js +10 -0
  6. package/cjs/CalmLensAppClient-CHQGu-Do.js +89 -0
  7. package/cjs/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
  8. package/cjs/CalmLensConnection-B8Bu2j3y.js +659 -0
  9. package/cjs/CalmLensServerClient-C5o2A8MW.js +645 -0
  10. package/cjs/CalmLensServerClient-JtP687a3.d.ts +235 -0
  11. package/cjs/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
  12. package/cjs/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
  13. package/cjs/CryptoUtils-B_x8lMIx-YBW1-bvL.js +42 -0
  14. package/cjs/FileIcons-Ob003MdS.js +175 -0
  15. package/cjs/Jurisdiction-FusEXb4U-zVs21tDv.js +91 -0
  16. package/cjs/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
  17. package/cjs/ModerationContextLimits-DelWfmiJ.js +1311 -0
  18. package/cjs/ReactUpload-CAyCsyG6.d.ts +17 -0
  19. package/cjs/ReactUpload-Cfg5JYUq.js +54 -0
  20. package/cjs/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
  21. package/cjs/TestTriggers-DTc_iMJF-BjMPq4_3.js +1327 -0
  22. package/cjs/UploadController-BGO1eyzb.js +618 -0
  23. package/{esm/index-C9Xw1aid.d.ts → cjs/WebhookPayload-2aL31v0J.d.ts} +131 -155
  24. package/cjs/app.d.ts +7 -44
  25. package/cjs/app.js +11 -89
  26. package/cjs/elements.d.ts +88 -0
  27. package/cjs/elements.js +555 -0
  28. package/cjs/file-icons.d.ts +67 -0
  29. package/cjs/file-icons.js +10 -0
  30. package/cjs/index-CI4BzN4W.d.ts +252 -0
  31. package/cjs/index.d.ts +6 -4
  32. package/cjs/index.js +9 -4
  33. package/cjs/react-native.d.ts +56 -0
  34. package/cjs/react-native.js +495 -0
  35. package/cjs/react.d.ts +30 -0
  36. package/cjs/react.js +370 -0
  37. package/cjs/server.d.ts +12 -191
  38. package/cjs/server.js +18 -378
  39. package/cjs/test-inputs.d.ts +2 -2
  40. package/cjs/test-inputs.js +9 -264
  41. package/cjs/testing.d.ts +285 -190
  42. package/cjs/testing.js +2393 -1203
  43. package/cjs/ui-CC1GOoMg.js +583 -0
  44. package/cjs/ui-wIiMfqMB.d.ts +526 -0
  45. package/cjs/ui.d.ts +2 -0
  46. package/cjs/ui.js +27 -0
  47. package/cjs/vue.d.ts +490 -0
  48. package/cjs/vue.js +419 -0
  49. package/esm/BrowserUpload-BT091eOB.d.ts +11 -0
  50. package/esm/BrowserUpload-D2xBWqhm.js +5 -0
  51. package/esm/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
  52. package/esm/CalmLensAppClient-DuFas_dW.js +84 -0
  53. package/esm/CalmLensConnection-MxwwLXdl.js +582 -0
  54. package/esm/CalmLensServerClient-D80_mKpW.js +586 -0
  55. package/esm/CalmLensServerClient-JtP687a3.d.ts +235 -0
  56. package/esm/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
  57. package/esm/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
  58. package/esm/CryptoUtils-B_x8lMIx-DhuWGzqn.js +25 -0
  59. package/esm/FileIcons-M6dzjFxd.js +128 -0
  60. package/esm/Jurisdiction-FusEXb4U-1ov6m5yE.js +46 -0
  61. package/esm/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
  62. package/esm/ModerationContextLimits-BaX2QnU8.js +1114 -0
  63. package/esm/ReactUpload-CAyCsyG6.d.ts +17 -0
  64. package/esm/ReactUpload-CL2XC8WS.js +37 -0
  65. package/esm/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
  66. package/esm/TestTriggers-DTc_iMJF-HfB533Gx.js +1267 -0
  67. package/esm/UploadController-qjoYGCwx.js +589 -0
  68. package/{cjs/index-C9Xw1aid.d.ts → esm/WebhookPayload-2aL31v0J.d.ts} +131 -155
  69. package/esm/app.d.ts +7 -44
  70. package/esm/app.js +3 -86
  71. package/esm/elements.d.ts +88 -0
  72. package/esm/elements.js +528 -0
  73. package/esm/file-icons.d.ts +67 -0
  74. package/esm/file-icons.js +1 -0
  75. package/esm/index-CI4BzN4W.d.ts +252 -0
  76. package/esm/index.d.ts +6 -4
  77. package/esm/index.js +1 -1
  78. package/esm/react-native.d.ts +56 -0
  79. package/esm/react-native.js +467 -0
  80. package/esm/react.d.ts +30 -0
  81. package/esm/react.js +343 -0
  82. package/esm/server.d.ts +12 -191
  83. package/esm/server.js +3 -369
  84. package/esm/styles.css +147 -0
  85. package/esm/svelte/CalmLensMedia.svelte +134 -0
  86. package/esm/svelte/CalmLensUploader.svelte +175 -0
  87. package/esm/svelte/Icon.svelte +7 -0
  88. package/esm/svelte/index.d.ts +37 -0
  89. package/esm/svelte/index.js +6 -0
  90. package/esm/test-inputs.d.ts +2 -2
  91. package/esm/test-inputs.js +1 -256
  92. package/esm/testing.d.ts +285 -190
  93. package/esm/testing.js +2329 -1122
  94. package/esm/ui-CCY3yXFn.js +458 -0
  95. package/esm/ui-wIiMfqMB.d.ts +526 -0
  96. package/esm/ui.d.ts +2 -0
  97. package/esm/ui.js +3 -0
  98. package/esm/vue.d.ts +490 -0
  99. package/esm/vue.js +392 -0
  100. package/package.json +118 -13
  101. package/cjs/CalmLensConnection-BNUHp_AQ.js +0 -234
  102. package/cjs/CalmLensUtils-o171ownK.js +0 -699
  103. package/cjs/CalmLensVersion-DwhgxQK5.js +0 -480
  104. package/cjs/CalmLensWebhooks-BKrq30Yp.js +0 -201
  105. package/cjs/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
  106. package/cjs/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
  107. package/cjs/WebhookPayload-CJoIkGBD.d.ts +0 -11753
  108. package/esm/CalmLensConnection-XTqmlwzG.js +0 -193
  109. package/esm/CalmLensUtils-DsF8Aa_x.js +0 -574
  110. package/esm/CalmLensVersion-DcifRKsx.js +0 -415
  111. package/esm/CalmLensWebhooks-BvGyk5d3.js +0 -148
  112. package/esm/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
  113. package/esm/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
  114. package/esm/WebhookPayload-CJoIkGBD.d.ts +0 -11753
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
- # @calmlens/sdk
1
+ # CalmLens JavaScript SDK
2
2
 
3
- Official JavaScript/TypeScript SDK for CalmLens API
4
-
5
- CalmLens provides content moderation and classification for text, images, documents and websites, plus optional storage and CDN delivery. This SDK makes it easy to integrate CalmLens into your JavaScript/TypeScript applications.
3
+ The official JavaScript and TypeScript SDK for [CalmLens](https://calmlens.com): content moderation and classification for text, images, documents and websites, plus optional storage and CDN delivery.
6
4
 
7
5
  ## Installation
8
6
 
9
7
  ```bash
10
- npm install @calmlens/sdk
8
+ npm install calmlens
11
9
  ```
12
10
 
11
+ The same package is also published as `@calmlens/sdk`, at the same version. Import from the name you installed: `calmlens/server` with `calmlens`, or `@calmlens/sdk/server` with `@calmlens/sdk`.
12
+
13
13
  The package has two clients, each with its own import:
14
14
 
15
- | Import | Client | Runs | Authenticates with |
16
- | --- | --- | --- | --- |
17
- | `@calmlens/sdk/server` | `CalmLensServerClient` | On your server: Node.js 22 or later, Bun, Deno, Cloudflare Workers | A project API key |
18
- | `@calmlens/sdk/app` | `CalmLensAppClient` | In browsers and React Native apps | An [upload preset](#browser-and-app-uploads-with-upload-presets), with an optional upload token from your server |
15
+ | Import | Client | Runs | Authenticates with |
16
+ | ----------------- | ---------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
17
+ | `calmlens/server` | `CalmLensServerClient` | On your server: Node.js 22 or later, Bun, Deno, Cloudflare Workers | A project API key |
18
+ | `calmlens/app` | `CalmLensAppClient` | In browsers and React Native apps | An [upload preset](#browser-and-app-uploads-with-upload-presets), with an optional upload token from your server |
19
19
 
20
- `@calmlens/sdk` itself exports the shared types, `CalmLensError`, the error codes and `SDK_VERSION`. 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, `CalmLensAppClient` about 19 KB and `CalmLensServerClient` about 28 KB.
20
+ `calmlens` itself exports the shared types, `CalmLensError`, the error codes and `SDK_VERSION`. 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, `CalmLensAppClient` about 19 KB and `CalmLensServerClient` about 28 KB.
21
21
 
22
22
  API keys are secret, so `CalmLensServerClient` refuses to start in a web page or app. Upload from browsers and apps with `CalmLensAppClient`, and serve private content to them with [signed URLs](#assetscreatesignedurloptions).
23
23
 
@@ -28,7 +28,7 @@ Every public SDK operation accepts one options object. This keeps call sites sta
28
28
  Create an API key under **Developer → API Keys** in the CalmLens portal. The same page shows your **Project ID**. Keep both on your server.
29
29
 
30
30
  ```typescript
31
- import { CalmLensServerClient } from "@calmlens/sdk/server";
31
+ import { CalmLensServerClient } from "calmlens/server";
32
32
 
33
33
  const client = new CalmLensServerClient({
34
34
  apiKey: process.env.CALMLENS_API_KEY!,
@@ -36,12 +36,13 @@ const client = new CalmLensServerClient({
36
36
  });
37
37
 
38
38
  // Text (comments, captions, bios): waits for the decision
39
- const comment = await client.text.moderate({ text: "Great photo!", externalId: "comment-42" });
39
+ const comment = await client.text.classify({ text: "Great photo!", externalId: "comment-42" });
40
40
 
41
- if (comment.moderation.status === "approved") {
42
- // publish it; if comment.moderation.action is "alert", also queue it for review
41
+ const { status } = comment.classification.moderation;
42
+ if (status === "allowed" || status === "limited") {
43
+ // publish it; keep "limited" out of recommendations, trending and notifications
43
44
  } else {
44
- // "rejected": don't publish it
45
+ // "age-restricted": show it only behind your own age check; "blocked" or "purged": don't publish it
45
46
  }
46
47
 
47
48
  // Images and documents: queued, resolved by webhook or assets.retrieve()
@@ -50,27 +51,28 @@ const image = await client.assets.create({
50
51
  content: { data: file, type: "image/jpeg" },
51
52
  externalId: "post-7",
52
53
  });
53
- console.log(image.processing.status, image.moderation.status); // "processing" "pending"
54
+ console.log(image.classification.status, image.classification.moderation.status); // "processing" "pending"
54
55
  ```
55
56
 
56
- | `processing.status` | `moderation.status` | `moderation.action` | Meaning |
57
- | --- | --- | --- | --- |
58
- | `done` | `approved` | `null` | Safe to show. |
59
- | `done` | `approved` | `alert` | Show it, but your policy asked to be alerted. |
60
- | `done` | `rejected` | `hide` or `purge` | Your policy hid or purged it. Don't show it. |
61
- | `uploading` or `processing` | `pending` | `null` | Still being received or classified. |
62
- | `failed` | `pending` | `null` | Processing failed; see `assets.retry`. |
57
+ | `classification.status` | `classification.moderation.status` | Meaning |
58
+ | ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `completed` | `allowed` | Safe to show. |
60
+ | `completed` | `age-restricted` | Delivery requires `viewerClaims.ageConfirmed=true`, including signed URLs and API keys. Show it only to viewers your app has age-checked. |
61
+ | `completed` | `blocked` | Your policy blocked it; the CDN doesn't deliver it. Don't show it. |
62
+ | `completed` | `purged` | Your policy blocked it and deleted its content. Don't show it. |
63
+ | `pending` or `processing` | `pending` | Still being received or classified; `upload.status` is `uploading` while parts are arriving. |
64
+ | `failed` | `pending` | Processing failed; see `assets.retry`. |
63
65
 
64
- A reviewer's decision (`moderation.reviewerAction`) takes precedence over the automated one in
65
- `moderation.status`; `moderation.action` keeps what your policy did.
66
+ The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision in
67
+ `classification.moderation.status`.
66
68
 
67
- See [How CalmLens works](https://calmlens.com/docs/how-it-works) and [Testing your integration](https://calmlens.com/docs/guides/testing) for test inputs that trigger rejections safely.
69
+ See [How CalmLens works](https://calmlens.com/docs/how-it-works) and [Testing your integration](https://calmlens.com/docs/guides/testing) for test inputs that trigger each moderation status safely.
68
70
 
69
71
  ## API Reference
70
72
 
71
73
  ### CalmLensServerClient
72
74
 
73
- The client for your server, with every API method. Import it from `@calmlens/sdk/server`.
75
+ The client for your server, with every API method. Import it from `calmlens/server`.
74
76
 
75
77
  #### Constructor
76
78
 
@@ -111,7 +113,8 @@ async assets.create(options: SubmitAssetOptions): Promise<Asset>
111
113
  - `part` (optional): First part of a sequential multipart upload
112
114
  - `description` (string, optional): Description of the asset
113
115
  - `processingOptions.keepContentAfterProcessing` (boolean, optional): Retain content after processing
114
- - `processingOptions.features` (object, optional): Optional analyses to run, each turned on with `enabled: true`: `ocr`, and in beta `personDetection` and `faceDetection` (with `ageEstimation`). Person and face results are returned in `asset.report.features`. See [People and faces](https://calmlens.com/docs/guides/people-and-faces).
116
+ - `processingOptions.storeSensitiveMetadata` (boolean, optional): Overrides the project's `storeSensitiveMetadata` setting for this upload. When false, embedded image metadata (EXIF, XMP and IPTC) is removed before the image is stored, and `content.fileMetadata` leaves out the creator and location. When true, the image is stored as uploaded.
117
+ - `processingOptions.features` (object, optional): Optional analyses to run, each turned on with `enabled: true`: `ocr`, and in beta `personDetection` and `faceDetection` (with `ageEstimation`). See [People and faces](https://calmlens.com/docs/guides/people-and-faces).
115
118
  - `visibility` (string, optional): "public" or "private"
116
119
 
117
120
  **Example:**
@@ -124,43 +127,63 @@ const asset = await client.assets.create({
124
127
  type: "application/pdf",
125
128
  },
126
129
  description: "Important document",
127
- visibility: "private"
130
+ visibility: "private",
128
131
  });
129
132
 
130
133
  // Submit from URL
131
134
  const asset = await client.assets.create({
132
135
  name: "website-content",
133
136
  url: "https://example.com/page",
134
- description: "Web page to moderate"
137
+ description: "Web page to classify",
135
138
  });
136
139
  ```
137
140
 
138
- ##### assets.moderate(options) and text.moderate(options)
141
+ ##### assets.classify(options) and text.classify(options)
139
142
 
140
- Submit an asset and wait for classification to finish. Accepts the same options as `assets.create()` for text, images, documents, and URLs. `text.moderate({ text, name?, externalId?, ... })` is a shortcut that sends `text` as `text/plain` content named `text` by default.
143
+ Submit an asset and wait for classification to finish. Accepts the same options as `assets.create()` for text, images, documents, and URLs. `text.classify({ text, name?, externalId?, ... })` is a shortcut that sends `text` as `text/plain` content named `text` by default.
141
144
 
142
145
  ```typescript
143
- async assets.moderate(options: SubmitAssetOptions): Promise<Asset>
144
- async text.moderate(options: ModerateTextOptions): Promise<Asset>
146
+ async assets.classify(options: SubmitAssetOptions): Promise<Asset>
147
+ async text.classify(options: ClassifyTextOptions): Promise<Asset>
145
148
  ```
146
149
 
147
150
  Both resolve to the stored `Asset`, a plain object with camelCase keys exactly as the API returns it:
148
151
 
149
152
  ```typescript
150
- const asset = await client.text.moderate({ text: "Great photo!", externalId: "comment-42" });
151
- asset.id; // string
152
- asset.processing.status; // "uploading" | "processing" | "done" | "failed"; audio and video keep processing in the background
153
- asset.moderation.status; // "pending" | "approved" | "rejected"
154
- asset.moderation.action; // "alert" | "hide" | "purge", what your policy did with flagged content; null when nothing was flagged
155
- asset.moderation.report?.score; // number, the overall score
156
- asset.moderation.report?.categories.harassment.status; // "evaluated" | "partial" | "not-evaluated"
157
- asset.content.status; // "temporary" | "stored" | "removed"
153
+ const asset = await client.text.classify({ text: "Great photo!", externalId: "comment-42" });
154
+ asset.id; // string
155
+ asset.classification.status; // "pending" | "processing" | "completed" | "failed"; audio and video keep processing in the background
156
+ asset.classification.failureReason; // "file-too-large" | "duration-limit" | "processing-error" | ... | null; set only when processing failed
157
+ asset.classification.moderation.status; // "pending" | "allowed" | "limited" | "age-restricted" | "blocked" | "purged"
158
+ asset.classification.moderation?.score; // number, the overall score
159
+ asset.classification.moderation.raw?.categories.harassment.status; // "evaluated" | "partial" | "not-evaluated"
160
+ asset.content.status; // "temporary" | "stored" | "removed"
158
161
  asset.content.removalReason; // "policy" | "retention" | "requested" | "parent-removed" | "disposed" | null
159
- asset.type; // "plaintext-document" for text
160
- asset.createdAt; // milliseconds since the Unix epoch
162
+ asset.type; // "plaintext-document" for text
163
+ asset.createdAt; // milliseconds since the Unix epoch
161
164
  ```
162
165
 
163
- Always present: `id`, `projectId`, `name`, `type`, `createdAt`, `updatedAt`, `processing`, `moderation` and `content` (with their `status` fields). Other fields may be `null`: `moderation.report` until classification finishes (and when processing failed), `moderation.action` when nothing was flagged, the `moderation.reviewer*` fields until a reviewer decides, `externalId`, `description`, `metadata`, `folderId`, `visibility` and `processingOptions` when you did not set them, and `content.format`, `content.sizeInBytes`, `content.image`, `content.previewImage` and `content.extractedText` when they do not apply to the content. Content is `temporary` until processing finishes and then `removed` (reason `retention`) unless you set `processingOptions.keepContentAfterProcessing`. Inside a report, a category's `score` and `likelihood` exist only when its `status` is `evaluated` or `partial` (see [Moderation Reports](#moderation-reports)).
166
+ Always present: `id`, `projectId`, `name`, `type`, `createdAt`, `updatedAt`, `upload`, `classification` and `content`. Classification contains its progress, completion time and results grouped by capability. `classification.moderation.raw` is absent until findings are available; optional detections appear only when requested. Moderation decisions and reviewer details sit under `classification.moderation`. Content is removed after classification unless `processingOptions.keepContentAfterProcessing` is enabled. Check each category’s evaluation `status` before reading its score.
167
+
168
+ ###### Replies in a conversation
169
+
170
+ For a reply or comment in a thread, pass who wrote it and the earlier posts, oldest first. CalmLens moderates only the content; the earlier posts help it tell who the content addresses and whether it quotes, reports, counters or mocks something. Author IDs are your own opaque identifiers. The SDK sends only the posts CalmLens uses: the most recent 5 that fit in 2,000 characters (the most recent post is always sent).
171
+
172
+ ```typescript
173
+ const reply = await client.text.classify({
174
+ text: "Same to you",
175
+ authorId: "user-2",
176
+ context: {
177
+ items: [
178
+ { text: "Has anyone tried the new update?", authorId: "user-1" },
179
+ { text: "You would say that", authorId: "user-3" },
180
+ ],
181
+ },
182
+ });
183
+ reply.context; // the posts CalmLens used, or null once the content is removed
184
+ ```
185
+
186
+ The same options work on `assets.create()` and `assets.classify()` for images, documents, audio and video. See [Threaded moderation](https://calmlens.com/docs/guides/threaded-moderation).
164
187
 
165
188
  ##### assets.retrieve(options)
166
189
 
@@ -172,14 +195,14 @@ async assets.retrieve(options: { assetId: string }): Promise<Asset>
172
195
 
173
196
  **Example:**
174
197
  ```typescript
175
- const asset = await client.assets.retrieve({ assetId: "asset-uuid-here" });
176
- console.log(asset.moderation.status);
177
- console.log(asset.moderation.report); // Classification results
198
+ const asset = await client.assets.retrieve({ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c" });
199
+ console.log(asset.classification.moderation.status);
200
+ console.log(asset.classification.moderation); // Classification results
178
201
  ```
179
202
 
180
203
  ##### ensureLens(options)
181
204
 
182
- Create a project-scoped named lens, or update it safely. A lens can start at any positive version. Repeating the exact same version and definition is idempotent; any other update must use a version greater than the current one. A stale or conflicting version throws `lens/version-conflict`.
205
+ Create a project-scoped named lens, or update it safely. A lens can start at any positive version. Repeating the exact same version and definition is idempotent; any other update must use a version greater than the current one. A stale or conflicting version throws `lens/version-conflict`. It sends the definition through `PUT /v1/projects/{projectId}/lenses`, the request `ensureLenses()` uses, and skips the request when the client already ensured the same definition.
183
206
 
184
207
  ```typescript
185
208
  const lens = await client.ensureLens({
@@ -208,7 +231,7 @@ For several lenses, use `ensureLenses()` to ensure them together: new definition
208
231
  const [normal, uhd] = await client.ensureLenses({ lenses: [normalLens, uhdLens] });
209
232
  ```
210
233
 
211
- The CDN uses named lenses with an `@` prefix in URLs, such as `/content/@picmagic-normal`. The SDK adds it automatically when you supply `lens`. Use `transform` for one-off direct transforms, either as structured steps, for example `client.cdn.serve({ assetIdentifier: assetId, transform: { type: "imageTransform", options: { format: "png", width: 1024 } } })`, or as a selector string such as `"imageTransform[format=png,width=1024]"`. Pass an array of steps to chain transforms. `assets.createSignedUrl` accepts the same forms. Supply either `lens` or `transform`, not both. The resolved lens version is part of CalmLens's CDN cache and ETag identity, so changing a lens with the next version invalidates prior derived output.
234
+ The CDN uses named lenses with an `@` prefix in URLs, such as `/rendered/@picmagic-normal`. The SDK adds it automatically when you supply `lens`. Use `transform` for one-off direct transforms, either as structured steps, for example `client.cdn.serve({ assetIdentifier: assetId, transform: { type: "imageTransform", options: { format: "png", width: 1024 } } })`, or as a selector string with one path segment per step, such as `"width=1024,fit=cover/blur=12"`. Pass an array of steps to chain transforms. `assets.createSignedUrl` accepts the same forms. Supply a `lens`, a `transform` or both; the transform runs after the lens. The resolved lens version is part of CalmLens's CDN cache and ETag identity, so changing a lens with the next version invalidates prior derived output.
212
235
 
213
236
  ##### assets.list(options)
214
237
 
@@ -218,7 +241,7 @@ List assets in your project with pagination.
218
241
  async assets.list(options: {
219
242
  cursor?: string;
220
243
  pageSize?: number;
221
- ordering?: Array<{ field: "createdAt" | "id"; direction: "ASC" | "DESC" }>;
244
+ ordering?: Array<{ field: "createdAt" | "id"; direction: "asc" | "desc" }>;
222
245
  filter?: string | null;
223
246
  }): Promise<{
224
247
  items: Asset[];
@@ -231,19 +254,22 @@ async assets.list(options: {
231
254
  **Example:**
232
255
  ```typescript
233
256
  const result = await client.assets.list({
234
- pageSize: 20
257
+ pageSize: 20,
235
258
  });
236
259
 
237
260
  console.log(`Received ${result.items.length} assets`);
238
- result.items.forEach(asset => {
239
- console.log(`${asset.name}: ${asset.moderation.status}`);
261
+ result.items.forEach((asset) => {
262
+ console.log(`${asset.name}: ${asset.classification.moderation.status}`);
240
263
  });
241
264
  ```
242
265
 
243
266
  To read every matching asset, iterate instead of paging by hand. Pages are fetched as you go using continuation cursors. Inserting or deleting other assets does not shift the cursor position. To page manually, pass `nextCursor` as `cursor` with the same filters and ordering until it is null. Lists are not snapshots; assets may still be deleted or change filter membership during traversal.
244
267
 
245
268
  ```typescript
246
- for await (const asset of client.assets.iterate({ filter: "moderation.status==rejected", pageSize: 100 })) {
269
+ for await (const asset of client.assets.iterate({
270
+ filter: "classification.moderation.status==blocked",
271
+ pageSize: 100,
272
+ })) {
247
273
  console.log(asset.id);
248
274
  }
249
275
  ```
@@ -265,12 +291,12 @@ for await (const child of client.assets.iterateChildren({ assetId: parentAssetId
265
291
 
266
292
  ##### assets.createSignedUrl(options)
267
293
 
268
- Create a short-lived URL for private CDN content or previews.
294
+ Create a short-lived URL for a private asset's `rendered` (default) or `original` resource.
269
295
 
270
296
  ```typescript
271
297
  const signed = await client.assets.createSignedUrl({
272
298
  assetId,
273
- resource: "preview",
299
+ resource: "rendered",
274
300
  lens: "thumbnail",
275
301
  expiresInSeconds: 900,
276
302
  });
@@ -283,47 +309,108 @@ Move an asset into a folder, or to the project root by passing `null`.
283
309
  ```typescript
284
310
  // Move asset to a folder
285
311
  await client.assets.move({
286
- assetId: "asset-uuid",
287
- folderId: "folder-uuid",
312
+ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
313
+ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
288
314
  });
289
315
 
290
316
  // Move asset to project root
291
317
  await client.assets.move({
292
- assetId: "asset-uuid",
318
+ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
293
319
  folderId: null,
294
320
  });
295
321
  ```
296
322
 
297
323
  ##### assets.retry(options)
298
324
 
299
- Process an asset whose processing failed (`processing.status` `failed`) again. It returns to
325
+ Process an asset whose processing failed (`classification.status` `failed`) again. It returns to
300
326
  `processing` and is moderated like a new upload, so handle the result the same way (webhook or polling). Its content must still be stored:
301
327
  otherwise this throws `asset/content-unavailable`, and you need to upload it again.
302
328
 
303
329
  ```typescript
304
- const asset = await client.assets.retry({ assetId: "asset-uuid" });
305
- // asset.processing.status === "processing"
330
+ const asset = await client.assets.retry({ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c" });
331
+ // asset.classification.status === "processing"
332
+ ```
333
+
334
+ ##### assets.explain(options)
335
+
336
+ Complete the policy evidence behind a decided asset's moderation decision. To decide quickly,
337
+ moderation can skip questions whose answers could not change any action, so a report with
338
+ `classification.moderation.explanationComplete` `decision` can credit a different rule, or show a
339
+ different policy score, than a complete one. Explaining answers those questions and returns the asset
340
+ with `moderation.explanationComplete` `full`; every action stays as decided. It throws `asset/not-decided` for an
341
+ asset without a decision, and `asset/policy-changed` when the project's policy has changed since
342
+ the decision: pass `rerun: true` to moderate the asset again with the current policy instead. A
343
+ re-run returns the asset in `processing` and can reach a different decision; it throws
344
+ `asset/reviewed` for an asset a reviewer has decided.
345
+
346
+ ```typescript
347
+ const asset = await client.assets.explain({ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c" });
348
+ // asset.classification.moderation.explanationComplete === true
349
+ ```
350
+
351
+ ##### assets.decide(options)
352
+
353
+ Record a reviewer's decision on an asset, replacing the policy's. The asset's
354
+ `classification.moderation.status` becomes `decision` (`allowed`, `limited`, `age-restricted`,
355
+ `blocked` or `purged`), the decision and its `note` (required, up to 1,499 characters) join
356
+ `classification.moderation.manualReviews`, and the matching `asset.<status>` event is sent with a
357
+ `decided-by: reviewer` fact. Purging needs `confirmPurge: true` and the `assets:delete`
358
+ permission, and permanently deletes the stored content.
359
+
360
+ It throws `asset/processing` while the asset is still uploading or processing,
361
+ `asset/decision-not-allowed` when an asset pending moderation is allowed, limited or
362
+ age-restricted (it can only be blocked or purged), `asset/decision-unchanged` when the asset
363
+ already has that status and `asset/content-purged` when purged content is anything but blocked.
364
+
365
+ ```typescript
366
+ const asset = await client.assets.decide({
367
+ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
368
+ decision: "blocked",
369
+ note: "Spam reported by three members",
370
+ });
371
+ // asset.classification.moderation.status === "blocked"
372
+
373
+ await client.assets.decide({
374
+ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
375
+ decision: "purged",
376
+ note: "Illegal content",
377
+ confirmPurge: true,
378
+ });
306
379
  ```
307
380
 
308
381
  ##### assets.waitForDecision(options)
309
382
 
310
- Wait for an asset's moderation decision and get the decided asset: it returns once `moderation.status`
311
- is `approved` or `rejected`, or `processing.status` is `failed`. The SDK checks the asset every few seconds and rejects with `sdk/timeout` if there's no decision within
383
+ Wait for an asset's moderation decision and get the decided asset: it returns once `classification.moderation.status`
384
+ is no longer `pending`, or `classification.status` is `failed`. The SDK checks the asset every few seconds and rejects with `sdk/timeout` if there's no decision within
312
385
  `timeoutMs` (default 60000). Pass `signal` to stop waiting early (`sdk/cancelled`). Use it after
313
386
  `assets.create`, or with an asset ID you stored earlier. Webhooks report decisions without polling.
314
387
 
315
388
  ```typescript
316
- const queued = await client.assets.create({ name: "photo.jpg", content: { blob, type: "image/jpeg" } });
389
+ const queued = await client.assets.create({
390
+ name: "photo.jpg",
391
+ content: { blob, type: "image/jpeg" },
392
+ });
317
393
  const decided = await client.assets.waitForDecision({ assetId: queued.id, timeoutMs: 30_000 });
318
- if (decided.moderation.status === "rejected") hidePhoto(decided.id);
394
+ const { status } = decided.classification.moderation;
395
+ if (status !== "allowed" && status !== "limited") hidePhoto(decided.id);
319
396
  ```
320
397
 
321
- ##### folders.list()
398
+ ##### folders.list(options)
322
399
 
323
- List all folders in the current project.
400
+ Fetch one page of immediate child folders, sorted by name. Omit `parentId` for the project root.
401
+ The response contains `items`, the visible `ancestors` of the listed folders, `hasMore` and
402
+ `nextCursor`; pass `nextCursor` as `cursor` to load the next page. `pageSize` is 1 to 100
403
+ (default 50), and `ordering: [{ field: "createdAt", direction: "desc" }]` sorts by creation
404
+ time instead (`name` or `createdAt`, one rule).
324
405
 
325
406
  ```typescript
326
- const folders = await client.folders.list();
407
+ const page = await client.folders.list({ parentId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P", pageSize: 100 });
408
+ if (page.hasMore) {
409
+ const next = await client.folders.list({
410
+ parentId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
411
+ cursor: page.nextCursor!,
412
+ });
413
+ }
327
414
  ```
328
415
 
329
416
  ##### folders.retrieve(options)
@@ -331,7 +418,7 @@ const folders = await client.folders.list();
331
418
  Retrieve details of a specific folder by ID.
332
419
 
333
420
  ```typescript
334
- const folder = await client.folders.retrieve({ folderId: "folder-uuid" });
421
+ const folder = await client.folders.retrieve({ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P" });
335
422
  ```
336
423
 
337
424
  ##### folders.create(options)
@@ -355,7 +442,7 @@ Rename an existing folder.
355
442
 
356
443
  ```typescript
357
444
  const updated = await client.folders.update({
358
- folderId: "folder-uuid",
445
+ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
359
446
  name: "New Name",
360
447
  });
361
448
  ```
@@ -383,7 +470,7 @@ await client.folders.move({
383
470
  Delete an empty folder. If the folder still contains assets or child folders, this throws `folder/not-empty`.
384
471
 
385
472
  ```typescript
386
- await client.folders.delete({ folderId: "folder-uuid" });
473
+ await client.folders.delete({ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P" });
387
474
  ```
388
475
 
389
476
  ##### cdn.getAssetUrl(options) and cdn.serve(options)
@@ -393,25 +480,54 @@ await client.folders.delete({ folderId: "folder-uuid" });
393
480
  ```typescript
394
481
  const url = client.cdn.getAssetUrl({
395
482
  assetIdentifier: assetId,
396
- resource: "content",
483
+ resource: "rendered",
397
484
  lens: "thumbnail",
398
485
  });
399
486
 
487
+ // A public age-restricted asset, for a viewer your app has already age-checked
488
+ const restrictedUrl = client.cdn.getAssetUrl({
489
+ assetIdentifier: assetId,
490
+ viewerClaims: { ageConfirmed: true },
491
+ });
492
+
400
493
  const response = await client.cdn.serve({
401
494
  assetIdentifier: assetId,
402
- resource: "content",
495
+ resource: "original",
403
496
  });
404
497
  ```
405
498
 
406
499
  `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"`.
407
500
 
408
- Private CDN content cannot be read directly with API keys. Use a short-lived signed URL for application clients.
501
+ `serve` reads private assets with the client's API key when it has the **Download assets** (`assets:download`) permission. Give browsers and apps a short-lived signed URL instead.
502
+
503
+ A public asset whose moderation status is `age-restricted` is served from its public URL only when the URL carries
504
+ a JSON `viewerClaims` parameter containing `{ "ageConfirmed": true }`; otherwise delivery is refused, even with a signed URL or API key. Set it only
505
+ where your app has already decided the viewer may see age-restricted content.
506
+
507
+ `resource` is `rendered` (default, for display; takes lenses and transforms), `original` (the exact uploaded file) or `placeholder`. The placeholder is a small blurred PNG made from the
508
+ asset's ThumbHash, which the CDN serves publicly for every asset whatever its visibility or moderation status, for example in place of an
509
+ age-restricted image. It takes no `lens` or `transform`; passing one throws `sdk/invalid-argument`.
510
+
511
+ ##### Building CDN URLs in the browser
512
+
513
+ `getAssetUrl`, exported from `calmlens/app` and `calmlens`, builds the same URLs as `cdn.getAssetUrl` from
514
+ the project ID alone. It takes no API key and sends no request.
515
+
516
+ ```typescript
517
+ import { getAssetUrl } from "calmlens/app";
518
+
519
+ const placeholder = getAssetUrl({ projectId, assetIdentifier: assetId, resource: "placeholder" });
520
+ const thumbnail = getAssetUrl({ projectId, assetIdentifier: assetId, lens: "thumbnail" });
521
+ ```
522
+
523
+ It takes `projectId`, `assetIdentifier`, `resource`, `lens`, `transform`, `viewerClaims` and `cdnBaseUrl` (default
524
+ `https://cdn.calmlens.com/v1`).
409
525
 
410
526
  ##### Multipart upload flow
411
527
 
412
528
  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.
413
529
 
414
- 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.
530
+ 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 `classify` 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.
415
531
 
416
532
  Image upload sizes use distinct direct and multipart paths:
417
533
 
@@ -431,8 +547,8 @@ const uploading = await client.assets.create({
431
547
  name: "large-image.png",
432
548
  part: {
433
549
  uint8Array: firstPartBytes,
434
- type: "image/png"
435
- }
550
+ type: "image/png",
551
+ },
436
552
  });
437
553
 
438
554
  await client.assets.uploadPart({
@@ -455,15 +571,15 @@ const completed = await client.assets.uploadPart({
455
571
 
456
572
  **Example (single-call synchronous analysis):**
457
573
  ```typescript
458
- const analyzed = await client.assets.moderate({
574
+ const analyzed = await client.assets.classify({
459
575
  name: "large-image.png",
460
576
  part: {
461
577
  base64: finalPartBase64,
462
578
  type: "image/png",
463
- isFinal: true
464
- }
579
+ isFinal: true,
580
+ },
465
581
  });
466
- console.log(analyzed.moderation.status);
582
+ console.log(analyzed.classification.moderation.status);
467
583
  ```
468
584
 
469
585
  #### Static Methods
@@ -491,22 +607,28 @@ It returns `true` only when the `x-calmlens-signature` header contains a `v1=` H
491
607
  `CalmLensAppClient` uploads from a browser or app through an upload preset, without an API key. Create presets in the portal under Developer → Upload presets.
492
608
 
493
609
  ```ts
494
- import { CalmLensAppClient } from "@calmlens/sdk/app";
610
+ import { CalmLensAppClient } from "calmlens/app";
495
611
 
496
612
  // In the browser: no API key
497
613
  const client = new CalmLensAppClient({
498
- uploadPresetId: "your-upload-preset-id",
614
+ uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
499
615
  // Only for presets that require a token: called once per new upload
500
- token: () => fetch("/api/upload-token", { method: "POST" }).then((r) => r.json()).then((b) => b.token),
616
+ token: () =>
617
+ fetch("/api/upload-token", { method: "POST" })
618
+ .then((r) => r.json())
619
+ .then((b) => b.token),
501
620
  });
502
621
 
503
- const asset = await client.assets.moderate({ name: file.name, content: { data: file, type: file.type } });
622
+ const asset = await client.assets.classify({
623
+ name: file.name,
624
+ content: { data: file, type: file.type },
625
+ });
504
626
  // asset.status (the moderation status) is present when the preset shows decisions to uploaders
505
627
  ```
506
628
 
507
629
  **Options:** `uploadPresetId` (required), `token` (a string, or a function that returns one or a promise of one), plus `baseUrl`, `fetch`, `uploadFetch`, `timeoutMs`, `uploadTimeoutMs`, `maxRetries`, `maxResponseBytes` and `signal` as for `CalmLensServerClient`. Passing `apiKey` throws `sdk/invalid-config`.
508
630
 
509
- The client has `assets.create`, `assets.moderate`, `assets.uploadPart` and `assets.retrieve`, for its own uploads, and each returns an `UploadedAsset`. It fetches a new token for each upload and reuses that token for the upload's parts and status checks.
631
+ The client has `assets.create`, `assets.classify`, `assets.uploadPart` and `assets.retrieve`, for its own uploads, and each returns an `UploadedAsset`. It fetches a new token for each upload and reuses that token for the upload's parts and status checks.
510
632
 
511
633
  The same client works in React Native and Expo apps. Mobile apps send no `Origin` header, so use a preset that requires a token and leave its allowed sites empty. Pass the picked file's bytes, for example `content: { buffer: await new File(uri).arrayBuffer(), type }` with `expo-file-system`. See the [Calmsnap example](https://github.com/CalmLens/sdks/tree/main/examples/mobile-uploads-expo).
512
634
 
@@ -514,13 +636,72 @@ On your server, sign a single-use token for each upload with `CalmLensServerClie
514
636
 
515
637
  ```ts
516
638
  const { token, expiresAt } = await client.uploadTokens.sign({
517
- uploadPresetId: "your-upload-preset-id",
639
+ uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
518
640
  externalId: "post-42", // optional
519
641
  expiresInSeconds: 600, // optional, up to 3600
520
642
  });
521
643
  ```
522
644
 
523
- Server uploads can also pass `uploadPresetId` to `assets.create` or `assets.moderate` to apply a preset's folder, visibility, file types and size limit. See [Upload presets](https://calmlens.com/docs/guides/upload-presets).
645
+ Server uploads can also pass `uploadPresetId` to `assets.create` or `assets.classify` to apply a preset's folder, visibility, file types and size limit. See [Upload presets](https://calmlens.com/docs/guides/upload-presets).
646
+
647
+ ## Upload widget and media component
648
+
649
+ Ready-made front-end components for each UI library. They share one core, so they behave the same everywhere:
650
+
651
+ | Import | Components |
652
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
653
+ | `calmlens/react` | `CalmLensUploader`, `CalmLensMedia`, `useCalmLensUpload` (also works in Preact through `preact/compat`) |
654
+ | `calmlens/vue` | `CalmLensUploader`, `CalmLensMedia`, `useCalmLensUpload` |
655
+ | `calmlens/svelte` | `CalmLensUploader`, `CalmLensMedia` (Svelte 5) |
656
+ | `calmlens/elements` | `<calmlens-uploader>`, `<calmlens-media>` for plain HTML, Angular, Solid, Lit and anything else that renders custom elements |
657
+ | `calmlens/ui` | The framework-free core: `createUploadController`, `getMediaState`, messages and styles |
658
+
659
+ `react`, `vue` and `svelte` are optional peer dependencies: install the one you use.
660
+
661
+ **The uploader** is a drop zone with a file list. It uploads each file through `CalmLensAppClient`, shows byte progress, checks the upload until it has a moderation decision (for about 15 seconds; anything still pending is your webhook's to settle), and shows the decision or a message for each error code.
662
+
663
+ ```tsx
664
+ import { CalmLensUploader } from "calmlens/react";
665
+
666
+ <CalmLensUploader
667
+ uploadPresetId="preset_4Lk8Qw2Xm9Rt5Vb3Nc7P"
668
+ tokenUrl="/api/upload-token" // POSTs { name, type, size }; answer { token }
669
+ accept="image/*"
670
+ maxFileSize={10 * 1024 * 1024}
671
+ multiple
672
+ onUploaded={({ assetId, status, file }) => savePhoto(assetId)}
673
+ />;
674
+ ```
675
+
676
+ Options: `uploadPresetId`, `token` (a string, or a function of the file returning one), `tokenUrl`, `accept`, `maxFileSize`, `multiple`, `maxFiles`, `concurrency`, `decisionTimeoutMs`, `baseUrl`, `disabled`, `capture` and `messages`. Callbacks: `onUploaded` (not called for blocked or purged files), `onRejected`, `onFailed` and `onRemoved`; Vue emits `uploaded`, `rejected`, `failed` and `removed`, and the web component dispatches `calmlens-uploaded` and friends with the same `detail`.
677
+
678
+ To upload through your own API instead of a preset, pass `upload(file, { signal, onProgress })`, which returns `{ assetId, status? }`, and optionally `checkStatus(assetId)` for pending uploads. The uploader still shows progress, decisions and errors; throw a `CalmLensError` to show its code's message. `createProgressFetch(onProgress)` gives you a `fetch` that reports sent bytes, to pass as `uploadFetch` to your client.
679
+
680
+ **The media component** shows an asset from the CDN's `rendered` resource in a single `<img>`, so the CDN picks the best format the browser accepts. It adds `srcset` widths when you pass `widths` and `sizes`, shows the blurred placeholder while the image loads, and fades the image in. Video and audio play the `original` file, with a rendered poster for video. Age-restricted assets stay behind the placeholder and a notice with a reveal button until you pass `viewerClaims: { ageConfirmed: true }`. Blocked and purged assets, and images that fail to load, show an unavailable notice.
681
+
682
+ ```tsx
683
+ import { CalmLensMedia } from "calmlens/react";
684
+
685
+ <CalmLensMedia
686
+ projectId="proj_8fK2mQx7Lp4NvB3tRz9Y"
687
+ assetId={post.assetId}
688
+ status={post.status}
689
+ viewerClaims={{ ageConfirmed: viewer.ageConfirmed }}
690
+ onReveal={askForAgeCheck}
691
+ alt={post.alt}
692
+ aspectRatio={post.aspectRatio}
693
+ widths={[400, 800]}
694
+ sizes="(min-width: 768px) 50vw, 100vw"
695
+ />;
696
+ ```
697
+
698
+ Instead of `projectId` and `assetId`, you can pass an asset from the API as `asset`, for example `<CalmLensMedia asset={asset} alt="…" />`: it supplies the project, ID, moderation status, media type and stored preview (ThumbHash, average colour and size), and props you set win over it. On the web component, set the `asset` property.
699
+
700
+ It also takes `type` (`image`, `video` or `audio`), `resource`, `lens`, `transform`, `url` (a signed URL from your server), `placeholderHash` (the asset's `content.image.thumbhash`, drawn in the browser so no placeholder request is needed), `placeholderUrl`, `averageColor`, `fit`, `position`, `lazy`, `fetchPriority` and `controls`.
701
+
702
+ The React components render their stylesheet with them: React places it in the page's head once, on the server too. With Vue, Svelte or the web components, import `calmlens/styles.css` once (or link it). Nothing is added to the page at runtime. Theme the components with `--calmlens-accent`, `--calmlens-radius` and the other `--calmlens-*` properties, which take precedence on any element. To style the `calmlens-*` classes yourself, pass `unstyled` to the React components, or leave out the stylesheet import. All text can be replaced or translated with `messages`, keyed by message name or error code.
703
+
704
+ The [widget playground](https://github.com/CalmLens/sdks/tree/main/examples/widget-playground) runs every component against a mock API.
524
705
 
525
706
  ## Asset Types
526
707
 
@@ -535,26 +716,27 @@ Assets can be of the following types:
535
716
 
536
717
  ## Asset Status
537
718
 
538
- An asset's state is split in three:
719
+ Upload, classification and content each have a status; moderation has its own decision:
539
720
 
540
- - `processing.status`: `uploading` (a multipart upload is accepting more parts), `processing`, then `done` or `failed`
541
- - `moderation.status`: `pending` until moderation finishes, then `approved` or `rejected`. A reviewer's decision (`moderation.reviewerAction`) takes precedence over the automated one. `moderation.action` is what your policy did with flagged content (`alert`, `hide` or `purge`), or `null` when nothing was flagged; an `alert` leaves the asset `approved`.
721
+ - `upload.status`: `uploading` while source content is arriving, then `completed`
722
+ - `classification.status`: `pending`, `processing`, `completed` or `failed`, with `classification.failureReason` saying why processing failed (`null` otherwise): `file-too-large`, `duration-limit`, `resolution-limit`, `unsupported-format`, `undecodable-content`, `multiple-tracks`, `type-mismatch`, `free-upload-limit`, `child-failed` or `processing-error`
723
+ - `classification.moderation.status`: `pending` until moderation finishes, then `allowed`, `limited` (delivered like `allowed`, but marked so your app can reduce its reach, such as keeping it out of recommendations, trending and notifications, ranking it lower or collapsing it), `age-restricted` (delivered only with `viewerClaims.ageConfirmed=true`), `blocked` (not delivered) or `purged` (not delivered, content deleted). The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision; each review's `decision` is one of the five decided statuses.
542
724
  - `content.status`: `temporary` (kept only until processing finishes), `stored` or `removed`, with `content.removalReason` `policy` (purged), `retention` (`keepContentAfterProcessing` off), `requested`, `parent-removed` or `disposed`
543
725
 
544
726
  ## Moderation Reports
545
727
 
546
- Completed reports always contain the six parent categories: `sexual`, `violence`, `self-harm`, `harassment`, `hate`, and `criminal-activity`. Category-specific detail is nested under `qualifiers`, for example `violence.qualifiers.graphic`. Reports have no `subcategories` field or dedicated minor-safety placeholder. Retired generic qualifier IDs are rejected.
728
+ Completed reports always contain the eight parent categories: `sexual`, `violence`, `self-harm`, `harassment`, `hate`, `criminal-activity`, `personal-information`, and `profanity`. Category-specific detail is nested under `qualifiers`, for example `classification.moderation.raw.categories.violence.qualifiers["violence-graphic"]`. Reports have no `subcategories` field or dedicated minor-safety placeholder. Retired generic qualifier IDs are rejected.
547
729
 
548
730
  Always narrow on a category's `status` before reading `score`. An `evaluated` or `partial` result includes `score` and `likelihood`; a `not-evaluated` result includes a reason and represents unknown coverage, not a safe zero score.
549
731
 
550
732
  ```typescript
551
- const violence = asset.report?.categories.violence;
552
- if (violence?.status === 'evaluated' || violence?.status === 'partial') {
733
+ const violence = asset.classification.moderation.raw?.categories.violence;
734
+ if (violence?.status === "evaluated" || violence?.status === "partial") {
553
735
  console.log(violence.score);
554
736
  }
555
737
  ```
556
738
 
557
- See the [moderation report guide](https://calmlens.com/docs/guides/moderation-reports) for aggregation, provider coverage, OCR merging, and policy actions. General Sexual Content moderation does not estimate age or provide dedicated CSAM detection.
739
+ See the [classification results guide](https://calmlens.com/docs/guides/classification-results) for aggregation, provider coverage, OCR merging, and policy actions. General Sexual Content moderation does not estimate age or provide dedicated CSAM detection.
558
740
 
559
741
  ## Asset Visibility
560
742
 
@@ -566,7 +748,7 @@ See the [moderation report guide](https://calmlens.com/docs/guides/moderation-re
566
748
  Every failure is a `CalmLensError`, whether the API returned an error or the request never completed. Branch on its stable `code`:
567
749
 
568
750
  ```typescript
569
- import { CALMLENS_ERROR_CODES, CalmLensError } from "@calmlens/sdk";
751
+ import { CALMLENS_ERROR_CODES, CalmLensError } from "calmlens";
570
752
 
571
753
  try {
572
754
  await client.assets.retrieve({ assetId });
@@ -578,42 +760,42 @@ try {
578
760
  }
579
761
  ```
580
762
 
581
- | Property | Meaning |
582
- | --- | --- |
583
- | `code` | A stable identifier such as `asset/not-found` or `sdk/timeout`. `CALMLENS_ERROR_CODES` lists the documented ones |
584
- | `status` | The HTTP status, when the API responded |
585
- | `requestId` | The `X-Request-Id` response header. Include it when you contact support |
586
- | `headers` | The response headers, when the API responded |
587
- | `retryAfter` | Seconds to wait, from the `Retry-After` header (on `429` and `idempotency/request-in-progress`) |
588
- | `details` | Structured diagnostics from the API, such as validation issues and `traceId` |
589
- | `cause` | The underlying error, for transport failures |
763
+ | Property | Meaning |
764
+ | ------------ | --------------------------------------------------------------------------------------------------------------- |
765
+ | `code` | A stable identifier such as `asset/not-found` or `sdk/timeout`.`CALMLENS_ERROR_CODES` lists the documented ones |
766
+ | `status` | The HTTP status, when the API responded |
767
+ | `requestId` | The `X-Request-Id` response header (the error body's `requestId`). Include it when you contact support |
768
+ | `headers` | The response headers, when the API responded |
769
+ | `retryAfter` | Seconds to wait, from the `Retry-After` header (on `429` and `idempotency/request-in-progress`) |
770
+ | `details` | Structured diagnostics from the API, such as validation issues |
771
+ | `cause` | The underlying error, for transport failures |
590
772
 
591
- `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.
773
+ `error.isNotFound()` is true for `404`, and `error.isRetryable()` for transport failures, timeouts (`sdk/timeout`, including `waitForDecision`'s deadline), `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.
592
774
 
593
775
  Codes the SDK raises itself:
594
776
 
595
- | Code | Meaning |
596
- | --- | --- |
597
- | `sdk/invalid-argument` | A method argument is missing or invalid, for example an empty `assetId` |
598
- | `sdk/invalid-config` | The client options are invalid |
599
- | `sdk/transport-error` | The request could not be completed (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) |
600
- | `sdk/timeout` | A deadline passed |
601
- | `sdk/cancelled` | Your `AbortSignal` cancelled the request |
602
- | `sdk/invalid-response` | A successful response whose body is not the expected JSON |
603
- | `sdk/http-error` | An HTTP failure without a CalmLens error body, for example from a proxy |
604
- | `sdk/response-too-large` | A response larger than `maxResponseBytes` |
605
- | `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent` |
777
+ | Code | Meaning |
778
+ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
779
+ | `sdk/invalid-argument` | A method argument is missing or invalid, for example an empty `assetId` |
780
+ | `sdk/invalid-config` | The client options are invalid |
781
+ | `sdk/transport-error` | The request could not be completed (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) |
782
+ | `sdk/timeout` | A deadline passed |
783
+ | `sdk/cancelled` | Your `AbortSignal` cancelled the request |
784
+ | `sdk/invalid-response` | A successful response whose body is not the expected JSON, or a page that repeats a cursor the iterator already used |
785
+ | `sdk/http-error` | An HTTP failure without a CalmLens error body, for example from a proxy, or a redirect, which the SDK never follows |
786
+ | `sdk/response-too-large` | A response larger than `maxResponseBytes` |
787
+ | `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent` |
606
788
 
607
789
  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`.
608
790
 
609
791
  ## Retries and idempotency
610
792
 
611
- 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.
793
+ 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, timeouts (`sdk/timeout`, after `timeoutMs`), `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.
612
794
 
613
- `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:
795
+ `assets.create`, `assets.classify`, `text.classify` 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:
614
796
 
615
797
  ```typescript
616
- await client.text.moderate({
798
+ await client.text.classify({
617
799
  text: comment.body,
618
800
  externalId: `comment-${comment.id}`,
619
801
  idempotencyKey: `comment-${comment.id}`,
@@ -624,50 +806,90 @@ The API keeps a response for 24 hours. Reusing a key for a different request fai
624
806
 
625
807
  ## Forward compatibility
626
808
 
627
- The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `AssetProcessingStatus`, `AssetModerationStatus`, `AssetModerationAction`, `AssetReviewerAction`, `AssetContentStatus`, `AssetContentRemovalReason`, `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.
809
+ The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `AssetClassificationStatus`, `AssetModerationStatus`, `AssetModerationDecision`, `AssetContentStatus`, `AssetContentRemovalReason`, `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.
628
810
 
629
811
  ## Testing
630
812
 
631
- `@calmlens/sdk/testing` exports `MockCalmLensServerClient`, an in-memory stand-in for `CalmLensServerClient`,, in-memory storage implementations, mock API key helpers, webhook delivery builders and `generateMediaTestCode`, for testing without network requests. `generateMediaTestCode(signal, testInputKey)` returns the spoken or on-screen test phrase for your project's secret test input key, shown under **Assets → Try test inputs** in the portal; a phrase with another project's key is moderated normally.
813
+ `calmlens/testing` exports `MockCalmLensServerClient` and `MockCalmLensAppClient`, which run the real clients against an in-memory CalmLens API and CDN, in-memory storage implementations, mock API key helpers, webhook delivery builders and `generateMediaTestCode`, for testing without network requests. `generateMediaTestCode(signal, testInputKey)` asynchronously returns the spoken or on-screen test phrase authenticated with your project's secret test input key, shown under **Assets → Try it → Test inputs** in the portal; a phrase with another project's key is moderated normally.
814
+
815
+ The `calmlens`, `/server` and `/app` entry points do not load or export testing helpers, to keep production bundles small. Keep `/testing` imports in test files.
816
+
817
+ ```typescript
818
+ import { MockCalmLensServerClient, createMockApiKey } from "calmlens/testing";
819
+
820
+ const client = new MockCalmLensServerClient({
821
+ apiKey: createMockApiKey(),
822
+ projectId: "proj_8fK2mQx7Lp4NvB3tRz9Y",
823
+ });
824
+ ```
825
+
826
+ Like the API, the mock gives each new object an ID with its kind's prefix, an underscore and 20 random base62 characters, such as `asset_5Gm2Rk8XqT3wLp9VbN4c` or `fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P`. For predictable IDs, pass `mockOptions.createId`, which receives the prefix (`asset`, `fldr`, `hl`, `hle`, `lens`, `evt` or `rev`).
827
+
828
+ `MockCalmLensServerClient` is a `CalmLensServerClient`: every method runs the real client's code (validation, request building, response and error handling, the lens cache), and its `fetch` is an in-memory API that answers each request as CalmLens does, with the API's HTTP statuses, JSON bodies and error bodies (`code`, `message`, `details`) and an `X-Request-Id`. It accepts the same options as `CalmLensServerClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, `maxRetries`, ...) and validates them the same way, plus `mockOptions`; requests never leave the process, whatever `fetch` you pass. `maxRetries` defaults to 0, so a failed request fails once; set it to test retries. Waits take no real time: retry backoff and `assets.waitForDecision` only move the mock's clock on, so `waitForDecision` fails with `sdk/timeout` straight away for an asset that stays pending. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`), and `cdn.serve` requests are answered by the in-memory CDN.
829
+
830
+ The in-memory API behaves like CalmLens where tests depend on it: `assets.list` applies `ordering`, `folderId` and `filter` expressions (comparisons joined by `;`, which binds tighter than `,`, and grouped with parentheses; anything else throws `sdk/invalid-argument` rather than returning wrong results), with cursors that resume after the last asset returned and only continue the query they came from; `assets.search` runs the API's keyword search, where every word must appear in the name, ID, external ID, description, tags, type, content format or extension, or moderation status, with the API's scores and `matchedFields`; assets created in a row get increasing `createdAt` values; multipart parts must arrive in order, every part but the last is at least 5 MiB, images total 10 MiB to 20 MiB, parts add up to any declared `totalSizeInBytes`, and the queue mode chosen at the start applies to the final part; out-of-range `pageSize`, `limit` and search queries fail with the API's `validation/too-small` or `validation/too-big`; unset asset fields are `null`, as the API returns them; a `failed` decision sets `classification.failureReason` to `processing-error`; content is removed with reason `retention` once processing is done unless `keepContentAfterProcessing` is set, an automated `purged` outcome removes it with reason `policy`, and `assets.deleteContent` and a reviewer's `purged` decision remove it with reason `requested`; a reused `idempotencyKey` replays the first response; and `cdn.serve` returns CDN-like responses with JSON error bodies: blocked, purged and failed assets and removed content get `403` `asset/not-available` whatever the key, then private assets get `403` `asset/api-key-missing-permission` unless the key has `assets:download` (keys from `createMockApiKey()` have no grants); a classified image's `placeholder` is a PNG, public for every asset whatever its visibility or status, and refused once the asset is purged or its content removed. Originals carry the CDN's `Cache-Control`, `Cache-Tag` and protection headers, and images an `ETag` and `Expires`. `client.setApiKeyGrants(grants)` changes the key's grants mid-test. `client.decide(assetId, decision)` applies a reviewer's decision (`allowed`, `limited`, `age-restricted`, `blocked` or `purged`) and adds the matching `asset.<decision>` event with a `decided-by: reviewer` fact; `assets.decide` checks decisions like the API (`asset/processing` while an upload is open or still processing, `asset/decision-not-allowed`, `asset/decision-unchanged`, `asset/content-purged`) and records its note with the reviewer `An API key`; `folders.list` and `hashLists.list` return cursor pages like the API; `assets.delete`, `assets.deleteContent` and `assets.retry` add `asset.deleted`, `asset.content-removed` and `asset.retried` events; and every upload records `asset.uploaded`, then `asset.classified` and the automated `asset.<status>` once it is decided, or `asset.failed`. `await client.enqueueEvent({ type, ... })` adds an event that `events.list` and `events.pages` then return; one about an asset carries the asset as it is.
632
831
 
633
- The `@calmlens/sdk`, `/server` and `/app` entry points do not load or export testing helpers, to keep production bundles small. Keep `/testing` imports in test files.
832
+ `mockOptions.moderate` decides each asset the mock moderates. It receives the stored `asset`, its `content` (`bytes`, and `text` for a plain-text upload), the `trigger` (`upload`, `retry`, `rerun` or `complete`) and whether the API would `queued` the work, and returns a status (`pending`, `failed`, `allowed`, `limited`, `age-restricted`, `blocked` or `purged`) or a decision built with `mockDecision`. A decision fills the asset's report as CalmLens does: every category (unflagged ones evaluated with score 0), the policy's actions and `flaggedCategories`. Without `moderate`, queued uploads, retries and reruns stay `pending` and everything else is `allowed`. `client.completeProcessing(assetId, decision?)` finishes a pending asset as the pipeline would, with the decision given or the one `moderate` returns for `complete`:
634
833
 
635
834
  ```typescript
636
- import { MockCalmLensServerClient, createMockApiKey } from "@calmlens/sdk/testing";
835
+ import { MockCalmLensServerClient, createMockApiKey, mockDecision } from "calmlens/testing";
637
836
 
638
837
  const client = new MockCalmLensServerClient({
639
838
  apiKey: createMockApiKey(),
640
- projectId: crypto.randomUUID(),
839
+ projectId,
840
+ mockOptions: {
841
+ moderate: ({ content }) =>
842
+ content.text?.includes("kill")
843
+ ? mockDecision("blocked").flag("violence", 0.92, { "violence-threat": 0.9 })
844
+ : "allowed",
845
+ },
641
846
  });
847
+
848
+ const comment = await client.text.classify({ text: "I will kill you" });
849
+ expect(comment.classification.moderation.raw?.categories.violence).toMatchObject({ score: 0.92 });
642
850
  ```
643
851
 
644
- `MockCalmLensServerClient` accepts the same options as `CalmLensServerClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, ...) and validates them the same way, plus `mockOptions`. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`).
852
+ A `moderate` callback decides queued uploads straight away too; return `pending` for them (`queued && trigger === "upload"`) to finish them later. With the default, a queued upload stays pending until you finish it:
645
853
 
646
- 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; `mockOptions.queuedOutcome` (default `pending`) and `mockOptions.analyzedOutcome` (default `approved`) set where queued and waited-for uploads end up (`pending`, `approved`, `rejected` or `failed`); content is removed with reason `retention` once processing is done unless `keepContentAfterProcessing` is set, and `assets.deleteContent` removes it with reason `requested`; 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.
854
+ ```typescript
855
+ const upload = await client.assets.create({
856
+ name: "photo.png",
857
+ content: { base64: pngBase64, type: "image/png" },
858
+ }); // pending
859
+ await client.completeProcessing(upload.id, mockDecision("age-restricted").flag("sexual", 0.8));
860
+ ```
647
861
 
648
- 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.
862
+ Every request the client sends is recorded in `client.calls` as `{ operation, method, url, path, query, headers, body, status }`: the client method that sent it (`"assets.retrieve"`, `"events.list"`, `"cdn.serve"`, ...), the request as the API received it (`path` relative to the API or CDN base URL, `body` as parsed JSON, the text of a text upload or the bytes of a file) and the HTTP status of the answer (`undefined` when it failed without one). Methods that send nothing, such as `uploadTokens.sign` or an `ensureLens` answered from the lens cache, are not recorded. `client.callsTo(operation)` filters them; a request several methods send matches each of them, so `callsTo("text.classify")` also lists text uploads through `assets.classify`, and `callsTo("assets.waitForDecision")` the `assets.retrieve` requests it polls with. To test failure handling, `client.failNextRequest()` makes the next request fail like a network failure, so the client throws `sdk/transport-error`; pass `"timeout"` for `sdk/timeout`, an API error code such as `"upload/limit-exceeded"` for that API error with its HTTP status, a `CalmLensError` to throw it as is or another `Error` to fail with it as the cause, and `{ operation }` to fail only the next request that method sends. An API error also takes `details` and `retryAfter` (seconds, sent as `Retry-After`). Each queued failure fails one request, so a retry takes the next queued failure or reaches the API. `client.clearCalls()` forgets the requests recorded so far.
649
863
 
650
864
  ```typescript
651
- const client = new MockCalmLensServerClient({ apiKey: createMockApiKey(), projectId, timeoutMs: 5_000 });
865
+ const client = new MockCalmLensServerClient({
866
+ apiKey: createMockApiKey(),
867
+ projectId,
868
+ timeoutMs: 5_000,
869
+ });
652
870
 
653
- client.failNextRequest("timeout", { operation: "text.moderate" });
871
+ client.failNextRequest("timeout", { operation: "text.classify" });
654
872
  await expect(postComment(client, "hello")).rejects.toMatchObject({ code: "sdk/timeout" });
655
873
 
656
874
  await postComment(client, "hello");
657
- expect(client.callsTo("text.moderate")[0]?.options).toMatchObject({ text: "hello" });
875
+ expect(client.callsTo("text.classify").at(-1)).toMatchObject({ body: "hello", status: 200 });
658
876
  ```
659
877
 
660
878
  To test a webhook handler, build a signed delivery the way CalmLens sends it:
661
879
 
662
880
  ```typescript
663
- import { createWebhookHeaders, toWebhookEventPayload } from "@calmlens/sdk/testing";
881
+ import { createWebhookHeaders, toWebhookEventPayload } from "calmlens/testing";
664
882
 
665
- const event = client.enqueueEvent({ type: "asset.approved", assetId: asset.id });
883
+ const event = await client.enqueueEvent({ type: "asset.allowed", assetId: asset.id });
666
884
  const body = JSON.stringify(toWebhookEventPayload(event));
667
885
  const headers = await createWebhookHeaders({ secret, body, deliveryId: "delivery-1" });
668
886
  const response = await app.request("/api/webhooks/calmlens", { method: "POST", body, headers });
669
887
  ```
670
888
 
889
+ To compare upload tokens with known values, `signUploadToken(apiKey, options, { now, nonce })` signs with a fixed clock (milliseconds since the epoch) and nonce, checking `options` like `uploadTokens.sign`. The mock's own `uploadTokens.sign` signs at `mockOptions.now` with a random nonce.
890
+
891
+ `MockCalmLensAppClient` is the real `CalmLensAppClient` on the same in-memory API: it applies upload preset rules from `mockOptions.preset` (`requireToken`, `allowedContentTypes`, `maxFileSizeInBytes`, `showDecisions`, `visibility`, `keepContentAfterProcessing`, `folderId`) with the API's error codes, can check and continue only the uploads it made, and stores uploads in a `MockCalmLensServerClient`. Pass yours as `mockOptions.server` to check uploads from the server side and serve them from its CDN, or read the one it creates from `client.server`. To run the upload widget on it, pass `createClient: (options) => new MockCalmLensAppClient({ ...options, mockOptions })`.
892
+
671
893
  For `mockOptions`, deterministic timestamps and IDs, storage inspection, and testing recipes, see the [JavaScript SDK Testing Guide](https://calmlens.com/docs/sdk/javascript/testing).
672
894
 
673
895
  ## Webhooks
@@ -677,36 +899,36 @@ A `webhook` connector POSTs each event to your URL as JSON:
677
899
  ```json
678
900
  {
679
901
  "id": "…",
680
- "type": "asset.flagged",
681
- "createdAt": 1760000000000,
682
902
  "projectId": "…",
683
- "data": {
684
- "severity": "high",
685
- "title": "…",
686
- "description": null,
687
- "assetId": "…",
688
- "asset": null,
689
- "facts": [{ "key": "asset-name", "value": "photo.jpg" }]
690
- }
903
+ "type": "asset.blocked",
904
+ "severity": "high",
905
+ "title": "…",
906
+ "description": null,
907
+ "assetId": "…",
908
+ "asset": null,
909
+ "facts": [{ "key": "asset-name", "value": "photo.jpg" }],
910
+ "createdAt": 1760000000000
691
911
  }
692
912
  ```
693
913
 
914
+ The body is the event exactly as [`events.list`](#events-feed) returns it.
915
+
694
916
  Each delivery has these headers (the names are exported as constants):
695
917
 
696
- | Header | Constant | Value |
697
- |---|---|---|
698
- | `x-calmlens-delivery` | `WEBHOOK_DELIVERY_HEADER` | Delivery ID (the same across retries) |
699
- | `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
918
+ | Header | Constant | Value |
919
+ | ---------------------- | -------------------------- | ------------------------------------------------------------------ |
920
+ | `x-calmlens-delivery` | `WEBHOOK_DELIVERY_HEADER` | Delivery ID (the same across retries) |
921
+ | `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
700
922
  | `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
701
923
 
702
924
  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`.
703
925
 
704
- The result is typed as an extensible `WebhookEventPayload`. Event types and fact keys may be unfamiliar, and fact values are `unknown`; validate the facts your handler uses. Extra fields remain available. The [webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists current event types. Deliveries are retried, so make your handler idempotent by `event.id`.
926
+ The result is typed as an extensible `WebhookEventPayload`. Event types and fact keys may be unfamiliar, and fact values are `unknown`; validate the facts your handler uses. Extra fields remain available. `isTestWebhookEvent(event)` is `true` for a test send from the portal or the CLI, which carries the `{ key: "test", value: true }` fact. The [webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists current event types. Deliveries are retried, so make your handler idempotent by `event.id`.
705
927
 
706
928
  **Fetch API** (Next.js route handlers, Remix, Cloudflare Workers, Bun, Deno):
707
929
 
708
930
  ```typescript
709
- import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk/server";
931
+ import { CalmLensError, parseWebhookEvent } from "calmlens/server";
710
932
 
711
933
  export async function POST(request: Request) {
712
934
  let event;
@@ -717,7 +939,8 @@ export async function POST(request: Request) {
717
939
  secret: process.env.CALMLENS_WEBHOOK_SECRET!,
718
940
  });
719
941
  } catch (error) {
720
- if (CalmLensError.isCalmLensError(error)) return new Response("Invalid delivery", { status: 401 });
942
+ if (CalmLensError.isCalmLensError(error))
943
+ return new Response("Invalid delivery", { status: 401 });
721
944
  throw error;
722
945
  }
723
946
  // Queue the work, keyed by event.id, then respond quickly.
@@ -749,52 +972,59 @@ app.post("/api/webhooks/calmlens", express.raw({ type: "application/json" }), as
749
972
 
750
973
  ## Command-line tool
751
974
 
752
- Install the standalone [CalmLens CLI](https://calmlens.com/docs/guides/local-development) through Homebrew, a platform download or npm. Webhook connectors must use public HTTPS URLs, so during development use it to forward events to a local handler instead. Each event is sent with the same body, headers and signature as a real delivery:
975
+ This package includes the `calmlens` [CLI](https://calmlens.com/docs/guides/local-development). Because the package is named `calmlens`, you can run it without adding a dependency:
753
976
 
754
977
  ```bash
755
978
  npx calmlens listen --forward-to http://localhost:3000/api/webhooks/calmlens --env-file .env.local
756
979
  ```
757
980
 
758
- The CLI reads `CALMLENS_API_KEY`, `CALMLENS_PROJECT_ID` and `CALMLENS_WEBHOOK_SECRET` from the environment or `--env-file`; without a secret it prints a generated one for your app. `npx calmlens trigger [harassment|hate|criminal-activity|clean]` moderates a harmless test input so events appear (one upload each). Run `npx calmlens --help` for all options. To install the command globally, use `npm install -g calmlens`. The CLI is released separately from the SDK.
981
+ Install it globally with `npm install -g calmlens`. In a project that depends on the SDK, `npx calmlens` runs the installed version. Standalone executables for macOS, Linux and Windows, which need no Node.js, are attached to the [CLI releases](https://github.com/CalmLens/sdks/releases?q=cli-v&expanded=true) with a `SHA256SUMS` file, and Homebrew installs them:
982
+
983
+ ```bash
984
+ brew tap calmlens/sdks https://github.com/CalmLens/sdks
985
+ brew install calmlens/sdks/calmlens
986
+ ```
987
+
988
+ Webhook connectors must use public HTTPS URLs, so during development use `listen` to forward events to a local handler instead. Each event is sent with the same body, headers and signature as a real delivery. The CLI reads `CALMLENS_API_KEY`, `CALMLENS_PROJECT_ID` and `CALMLENS_WEBHOOK_SECRET` from the environment or `--env-file`, or uses the key from `calmlens login`; without a secret it prints a generated one for your app. `npx calmlens trigger [harassment|hate|criminal-activity|clean]` moderates a harmless test input so events appear (one upload each). Run `npx calmlens --help` for all options. The CLI's version is the SDK's version.
759
989
 
760
990
  The same feed is available in code with [`events.list`](#events-feed).
761
991
 
762
992
  ## Events feed
763
993
 
764
- `client.events.list({ after?, limit? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage`:
994
+ `client.events.list({ cursor?, pageSize? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage`:
765
995
 
766
996
  ```typescript
767
997
  {
768
- events: TypedEvent[]; // { id, projectId, type, severity, title, description?, assetId?, asset?, facts, createdAt, updatedAt }
769
- cursor: string; // pass back as `after` to continue after the last event returned
770
- hasMore: boolean; // true when more events are already waiting after `cursor`
998
+ items: TypedEvent[]; // { id, projectId, type, severity, title, description, assetId, asset, facts, createdAt }
999
+ nextCursor: string; // pass back as `cursor` to continue after the last event returned
1000
+ hasMore: boolean; // true when more events are already waiting after `nextCursor`
771
1001
  }
772
1002
  ```
773
1003
 
774
- - Without `after` (or with an empty string), it returns **no events** and a `cursor` for the current time, so you only see events created after you start reading. It does not return history.
775
- - With `after`, it returns up to `limit` events (default 50) created after that cursor. When there are none, `cursor` is the `after` you passed.
776
- - 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.
777
- - An unknown or malformed cursor fails with the `CalmLensError` code `events/invalid-cursor`.
1004
+ - Without `cursor`, it returns **no events** and a `nextCursor` for the current time, so you only see events created after you start reading. It does not return history.
1005
+ - With `cursor`, it returns up to `pageSize` events (1 to 100, default 50) created after that cursor. When there are none, `nextCursor` is the `cursor` you passed; it is never null.
1006
+ - Keep passing the latest `nextCursor` as `cursor` until `hasMore` is `false`, then wait before polling again. Store the cursor if you want to resume after a restart.
1007
+ - An unknown or malformed cursor fails with the `CalmLensError` code `validation/invalid-cursor`.
778
1008
 
779
- `client.events.pages({ after, limit })` follows the cursor for you: it yields each page until no more events are waiting.
1009
+ `client.events.pages({ cursor, pageSize })` follows the cursor for you: it yields each page until no more events are waiting.
780
1010
 
781
1011
  ```typescript
782
- let { cursor } = await client.events.list(); // no events, cursor = "now"
1012
+ let { nextCursor: cursor } = await client.events.list(); // no events, a cursor for "now"
783
1013
 
784
1014
  async function drainEvents() {
785
- for await (const page of client.events.pages({ after: cursor, limit: 100 })) {
786
- for (const event of page.events) {
1015
+ for await (const page of client.events.pages({ cursor, pageSize: 100 })) {
1016
+ for (const event of page.items) {
787
1017
  // narrow on event.type to read its typed facts
788
1018
  console.log(event.type, event.facts);
789
1019
  }
790
- cursor = page.cursor; // save it to resume after a restart
1020
+ cursor = page.nextCursor; // save it to resume after a restart
791
1021
  }
792
1022
  }
793
1023
 
794
1024
  setInterval(drainEvents, 5_000);
795
1025
  ```
796
1026
 
797
- Events carry `facts: { key, value, url? }[]`; webhook deliveries carry the same events reshaped as `{ id, type, createdAt, projectId, data }`.
1027
+ Events carry `facts: { key, value, url? }[]`; `description`, `assetId` and `asset` are `null` when the event has none. Webhook deliveries carry the same events in the same shape.
798
1028
 
799
1029
  ## Request budgets
800
1030
 
@@ -802,33 +1032,47 @@ Set `timeoutMs` (default 120000), `uploadTimeoutMs`, `maxRetries` or `signal` on
802
1032
 
803
1033
  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.
804
1034
 
805
- `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.
1035
+ `text.classify` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.classify` waits for classification.
806
1036
 
807
1037
  ## Releasing
808
1038
 
809
- Stable releases are published automatically from `main` when packed SDK contents differ from npm `latest` and an SDK Changeset is present. Contributors must run `bun run changeset` to choose the release type and write release notes. CI updates and commits this package's `CHANGELOG.md` automatically. See the repository's [.changeset release guide](../../.changeset/README.md).
1039
+ Run `bun run changeset` to choose the release type and write release notes. Merging the Version SDKs pull request releases the version and updates this package's `CHANGELOG.md`. See the repository's [.changeset release guide](../.changeset/README.md).
1040
+
1041
+ Once the release commit's SDK Tests and SDK examples runs pass, `.github/workflows/release-npm.yml` publishes one build to npm with provenance, as `calmlens` and as `@calmlens/sdk`. The CLI's standalone executables and Homebrew formula are released in this repository under `cli-v<version>` by `.github/workflows/release-cli.yml`. `bun run build:cli` builds the npm entry, `bin/calmlens.js` (the package build includes it), and `bun run build:cli:binary` builds a standalone executable in `dist/`; set `CALMLENS_BUILD_TARGET`, such as `bun-linux-x64`, to cross-compile it.
810
1042
 
811
1043
  ### Test-input images
812
1044
 
813
- The optional `@calmlens/sdk/test-inputs` entry generates and recognises the same
1045
+ The optional `calmlens/test-inputs` entry generates and recognises the same
814
1046
  project-specific colour grids used by the CalmLens portal:
815
1047
 
816
1048
  ```ts
817
- import { paintTestImage, detectTestImage } from "@calmlens/sdk/test-inputs";
818
-
819
- const width = 512;
820
- const height = 512;
821
- const rgba = paintTestImage("violence", projectTestInputKey, width, height);
822
- const signal = detectTestImage({ width, height, rgba }, projectTestInputKey);
823
- // signal === "violence"
1049
+ import {
1050
+ paintTestImage,
1051
+ detectTestImage,
1052
+ generateTestTextCode,
1053
+ TEST_INPUT_PRESETS,
1054
+ } from "calmlens/test-inputs";
1055
+
1056
+ const input = TEST_INPUT_PRESETS.mixed;
1057
+ const image = await paintTestImage(input, projectTestInputKey);
1058
+ const decoded = await detectTestImage(image, projectTestInputKey);
1059
+ const text = await generateTestTextCode(input, projectTestInputKey);
1060
+ // Encode image.rgba as a PNG using image.width and image.height.
824
1061
  ```
825
1062
 
826
1063
  Encode the returned RGBA bytes with your image library to download a PNG. To
827
1064
  recognise a downloaded image, decode it into RGBA first. These helpers have no
828
- DOM, image-codec, or Cloudflare dependencies. The grid tolerates resizing and
829
- lossy compression; cropping, rotation, and severe colour changes are unsupported.
1065
+ DOM, image-codec, or Cloudflare dependencies. Upload the PNG unchanged: resizing, cropping and lossy conversion invalidate its authenticated grid. Presets and custom instance definitions use the same format. Regenerate samples created with the older fixed-category format.
830
1066
  The entry also exports `generateTestTextCode` and `detectTestTrigger` for text
831
1067
  test inputs. Use your project's test input key; another project's key does not
832
1068
  activate its test signals.
833
1069
 
834
- Image moderation considers readable text directly. Optional OCR (`processingOptions.features.ocr.enabled: true` on the wire) adds a text moderation pass and stores retained text in the image’s `content.extractedText`. No separate text child is created. Read the image’s combined `moderation.report`; removing its content also removes the extracted text.
1070
+ Image moderation considers readable text directly. Optional OCR (`processingOptions.features.ocr.enabled: true` on the wire) adds a text moderation pass and stores retained text in the image’s `content.extractedText`. No separate text child is created. Read the image’s combined `classification.moderation`; removing its content also removes the extracted text.
1071
+
1072
+ ### Viewer context for CDN delivery
1073
+
1074
+ CDN methods accept optional viewer claims (`viewerClaims`, or the language's `viewer_claims` / `ViewerClaims` field). The object contains `region` and `ageConfirmed`, using the SDK's normal field naming. Region defaults to the country of the connection reaching CalmLens; age confirmation defaults to false. Proxies should forward the incoming viewer country rather than rely on their outgoing connection's country. CalmLens accepts these as customer assertions without independently verifying them.
1075
+
1076
+ Age-restricted content returns `403` with `viewer/age-confirmation-required` unless age confirmation is true, including API-key and signed-URL requests. Private assets separately require access permission. Blocks and safety holds cannot be overridden by claims. Applications that never use age-restricted content should leave confirmation false, including processing jobs.
1077
+
1078
+ Raw HTTP clients can send a JSON `viewerClaims` query parameter or the JSON `X-CalmLens-Viewer-Claims` header; the query takes precedence. Signed URLs include claims when created and must be forwarded unchanged. Asset retrieval returns stored moderation results without a request-specific `delivery` object. Proxy caches must not bypass current viewer and moderation checks; honour `private, no-store` responses.