# Ruby SDK reference

This page lists the Ruby SDK's settings and behaviour in detail. Start with the [Ruby SDK](/docs/sdk/ruby) page for installation, the method list and the main workflows.

## Client settings

`CalmLens::ServerClient.new` takes `api_key:` and `project_id:`, plus these optional settings:

| Setting               | Default                       | Meaning                                                                                         |
| --------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------- |
| `base_url:`           | `https://api.calmlens.com/v1` | The API base URL.                                                                               |
| `cdn_base_url:`       | `https://cdn.calmlens.com/v1` | The CDN base URL.                                                                               |
| `timeout:`            | 120 seconds                   | Each socket read or write, not a whole transfer.                                                |
| `open_timeout:`       | 10 seconds                    | Opening a connection.                                                                           |
| `upload_timeout:`     | None                          | Each attempt of a binary upload as a whole.                                                     |
| `max_retries:`        | 2                             | Retries of requests that are safe to repeat; see [Retries and timeouts](#retries-and-timeouts). |
| `retry_base_delay:`   | 0.5 seconds                   | The first retry's wait when the API gives no `Retry-After`.                                     |
| `max_response_bytes:` | 32 MiB                        | The largest response body the client reads.                                                     |

A URL with credentials, a query or a fragment, or one that isn't HTTP(S), raises `CalmLens::ConfigurationError` (`sdk/invalid-config`). `CalmLens::VERSION` holds the gem version.

## Options and field names

Every option name may be snake\_case or camelCase, at any depth, so options added to the API later work without an SDK update: `processing_options: {keep_content_after_processing: true}` is sent as `processingOptions.keepContentAfterProcessing`. Keys inside `metadata` are yours and are sent unchanged. Responses are Hashes with the API's camelCase String keys.

### Request options

Every method also accepts `request_options:`, which applies to that call only:

| Key                  | Meaning                                                                                                                               |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `idempotency_key`    | The `Idempotency-Key` to send. Uploads get a generated one when you omit it; passing one on another write makes that write retryable. |
| `timeout`            | Seconds allowed for each socket read or write.                                                                                        |
| `open_timeout`       | Seconds allowed to open a connection.                                                                                                 |
| `upload_timeout`     | Seconds allowed for each attempt of a binary upload.                                                                                  |
| `max_retries`        | Retries for this call.                                                                                                                |
| `max_response_bytes` | The largest response body this call reads.                                                                                            |

## Types

`sig/` holds RBS signatures for every public method, with record types for each option Hash and response in `sig/types.rbs`: `CalmLens::asset` (with `asset_classification`, `asset_moderation` and `asset_content`), `classification_report` (categories, qualifiers, labels, media segments and `features` results), `processing_options_input`, `folder`, `lens`, `event`, `events_page`, `asset_page`, `asset_search_response`, `signed_url` and others. Steep and other RBS tools check your calls and the response keys you read:

```ruby
asset = client.assets.classify(name: 'photo.jpg', content: {data: File.binread('photo.jpg'), type: 'image/jpeg'},
                               processing_options: {features: {ocr: {enabled: true}}})
asset['classification']['moderation']['status']       # CalmLens::asset_moderation_status
asset.dig('content', 'extractedText')                 # CalmLens::extracted_text?
```

The signatures describe snake\_case Symbol option keys; camelCase and String keys work at runtime but are not typed. Results whose fields depend on `status` (category, qualifier and feature results) are typed as one record with those fields optional: check `status`, then read `score` or `reason`. `metadata` values and API error `details` are any JSON (`CalmLens::json_value`).

## Conversation context

`assets.create`, `assets.classify` and `text.classify` take `author_id:` and `context:`, the earlier posts in the conversation, oldest first; see [Threaded moderation](/docs/guides/threaded-moderation).

```ruby
client.text.classify(text: 'Same to you', author_id: 'user-9',
                     context: {items: [{text: 'Nice photo!', author_id: 'user-1'}, {text: 'Thanks', author_id: 'user-9'}]})
```

The SDK sends only the posts CalmLens uses: the most recent 5 (`CalmLens::ModerationContext::USED_ITEMS`) that fit in 2,000 characters (`CalmLens::ModerationContext::USED_CHARACTERS`, counted as Unicode code points). The newest post is always sent. `CalmLens::ModerationContext.select_items(items)` returns the posts that would be sent. The asset returns `authorId` and the `context` used; `context` is `nil` once the content is removed.

## Upload content

Content is one of:

| Form                   | Use                       |
| ---------------------- | ------------------------- |
| `{type:, plain_text:}` | Text.                     |
| `{type:, base64:}`     | Base64-encoded bytes.     |
| `{type:, data:}`       | A binary String or an IO. |

An IO is streamed and never read into memory. A File, Tempfile or StringIO, or any IO given with `size_in_bytes:`, is sent with a `Content-Length`; a stream of unknown length, such as a pipe or socket, is sent with chunked transfer encoding. A stream that ends early or holds more bytes than declared fails with [`400 upload/invalid-payload`](/docs/api/assets#create-asset-error-upload-invalid-payload).

Multipart uploads start with `part:` on `assets.create` or `assets.classify` and continue with `assets.upload_part`. The queue mode chosen at the start is saved with the upload; pass `queue:` to `upload_part` only to override it. See [Multipart uploads](/docs/guides/multipart-uploads).

## Reviewer decisions

`assets.decide` replaces the policy's decision with a reviewer's: `classification.moderation.status` becomes the decision, the decision and note join `manualReviews`, and the matching `asset.<status>` event is sent with a `decided-by: reviewer` fact. Purging needs `confirm_purge: true` and the `assets:delete` permission, and permanently deletes the stored content. A reviewer decision does not change `classification.completedAt`.

```ruby
client.assets.decide(asset_id: asset_id, decision: 'blocked', note: 'Spam reported by three members')
client.assets.decide(asset_id: asset_id, decision: 'purged', note: 'Illegal content', confirm_purge: true)
```

## CDN URLs

`cdn.get_asset_url` builds a URL and `cdn.serve` fetches it with your API key. Both take `asset_identifier:` and a `resource:`:

| Resource             | Lens or transform                    |
| -------------------- | ------------------------------------ |
| `rendered` (default) | Accepted                             |
| `original`           | Rejected with `sdk/invalid-argument` |
| `placeholder`        | Rejected with `sdk/invalid-argument` |

`cdn.get_asset_url`, `cdn.serve` and `assets.create_signed_url` accept a `lens:`, a `transform:`, or both; the lens runs first. A transform is a selector string or structured steps (one Hash or an Array), serialized with sorted option keys, so equal transforms share a URL:

```ruby
client.cdn.get_asset_url(asset_identifier: asset_id, transform: 'format=png,width=1024')
client.cdn.get_asset_url(asset_identifier: asset_id, transform: {type: 'imageTransform', options: {width: 1024, format: 'png'}})
# => ".../rendered/format=png,width=1024" in both cases
```

A selector has one path segment per step, such as `fit=cover,width=640/format=webp`, and a lens and a transform are joined with `/` (`/rendered/@thumbnail/width=320`). Nested options are dotted (`border.color`) and hex colours drop the `#`. See [Lenses and transforms](/docs/guides/lenses).

`viewer_claims:`, such as `{age_confirmed: true}`, is added as a JSON `viewerClaims` query parameter after any lens or transform. Set `age_confirmed` only where your app has already decided the viewer may see age-restricted content; see [Viewer region](/docs/guides/jurisdiction-rules#viewer-region) for the `region` claim and proxies.

### Serving responses

`cdn.serve` returns a `CalmLens::Response` with `status`, `headers` (lowercase names), `success?`, `request_id`, `content_type` and `content_length`. With a block it yields the response with a streaming body, read with `each_chunk`; without one it returns the response with the body read. Read the body once, with `each_chunk` or `body`. Error statuses are returned, not raised.

### Signed URLs

`assets.create_signed_url(asset_id:, ...)` returns `{'url', 'expiresAt'}`. `expires_in_seconds:` is 1 to 86,400 (default 900), and `resource:` is `rendered` or `original`. Claims passed when the URL is created are part of it, so forward the URL unchanged. See [Signed URLs](/docs/guides/delivering-assets#signed-urls).

## Upload tokens

`client.upload_tokens.sign` signs a single-use token for an [upload preset](/docs/guides/upload-presets) locally with your API key, without a request:

```ruby
signed = client.upload_tokens.sign(
  upload_preset_id: 'preset_4Lk8Qw2Xm9Rt5Vb3Nc7P',
  external_id: 'post-42',     # optional: the uploaded asset gets this ID
  expires_in_seconds: 600     # optional: 1 to 3600, 600 by default
)
# signed['token'], signed['expiresAt'] (milliseconds since the Unix epoch)
```

## Folders

`folders.list` returns a cursor page with `items`, the visible `ancestors` of the listed folders, `nextCursor`, `hasMore`, `pageSize` and `ordering`. Set `parent_id:` to open a branch (omit it for the project root), `search:` to list only children whose name contains it, and one `ordering:` entry by `name` (the default, ascending) or `createdAt`. Only empty folders can be deleted. See [Pagination and filtering](/docs/guides/pagination-and-filtering).

## Hash lists

Add a 64-character PDQ hash, or an image asset whose hash CalmLens computed. Adding a hash already in the list returns its existing entry, and an asset without a perceptual hash fails with [`409 hash-list/asset-not-hashed`](/docs/api/hash-lists#create-hash-list-entry-error-hash-list-asset-not-hashed). See [Hash lists](/docs/guides/hash-lists).

```ruby
list = client.hash_lists.create(name: 'Removed images', action: 'block')
entry = client.hash_lists.add_entry(hash_list_id: list['id'], asset_id: asset_id, label: 'Report 1042')
page = client.hash_lists.list_entries(hash_list_id: list['id'], page_size: 50) # items, nextCursor, hasMore
client.hash_lists.update(hash_list_id: list['id'], action: 'flag', description: nil)
client.hash_lists.remove_entry(entry_id: entry['id'])
client.hash_lists.delete(hash_list_id: list['id']) # deletes its entries too
```

## Events

Each event has `id`, `projectId`, `type`, `severity`, `title`, `description`, `assetId`, `asset` (the asset as it was when the event was created), `facts` and `createdAt`; `description`, `assetId` and `asset` are `nil` when the event has none. `facts` is an array of `{'key', 'value', 'url'?}` entries registered for its `type`; see [Event facts](/docs/api/events#event-facts). An unknown cursor raises [`400 validation/invalid-cursor`](/docs/api/assets#list-assets-error-validation-invalid-cursor).

## Webhooks

`CalmLens.parse_webhook_event(payload:, secret:, ...)` takes either `signature:` and `timestamp:` or the request's `headers:`. `headers:` accepts a Hash with names in any case (an Array of repeated values is joined with `,`), `Net::HTTPHeader` or a Rack env. The header names are `CalmLens::WEBHOOK_SIGNATURE_HEADER`, `WEBHOOK_TIMESTAMP_HEADER` and `WEBHOOK_DELIVERY_HEADER`.

A delivery signed more than `CalmLens::WEBHOOK_SIGNATURE_TOLERANCE_SECONDS` (300) from your clock fails; change the window with `tolerance_seconds:` on `parse_webhook_event`, `verify_webhook_signature` or `verify_webhook_request`.

The returned `CalmLens::WebhookEvent` has `id`, `project_id`, `type`, `severity`, `title`, `description`, `asset_id`, `asset`, `facts`, `created_at` and `test?`. `to_payload` returns the whole body as received, including fields CalmLens adds later. A Sinatra or Rack handler that acknowledges first and works in the background:

```ruby
post '/webhooks/calmlens' do
  request.body.rewind
  event = CalmLens.parse_webhook_event(payload: request.body.read, headers: request.env,
                                       secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET'))
  enqueue(event.id, event.to_payload) # skip an event ID you have already queued
  204
rescue CalmLens::WebhookVerificationError
  400
end
```

To build signed deliveries for tests, see [Webhook handlers](/docs/sdk/ruby/testing#webhook-handlers).

## Errors

Every error is a `CalmLens::Error` with `code`, `message`, `details`, `status`, `request_id`, `headers`, `retry_after` and `cause`. Include `request_id` when you contact support. `not_found?` is true for a missing resource; `retryable?` is true for `sdk/transport-error`, `sdk/timeout`, [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress), `429`, `502`, `503` and `504`, whichever class carries them. Compare codes with the constants in `CalmLens::ErrorCodes` (the API's codes, with their HTTP statuses in `CalmLens::ErrorCodes::STATUSES`) and `CalmLens::SdkErrorCodes`.

| Class                                                                                                                                                                        | When                                                                                                                                                                       |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CalmLens::APIError` and its subclasses `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError` | The API answered with an error status, or with a redirect (`sdk/http-error` with the 3xx status), which is never followed.                                                 |
| `CalmLens::ConnectionError` (`sdk/transport-error`), `CalmLens::TimeoutError` (`sdk/timeout`)                                                                                | No complete response arrived, or `assets.wait_for_decision` had no decision in time.                                                                                       |
| `CalmLens::InvalidResponseError` (`sdk/invalid-response`, `sdk/response-too-large`)                                                                                          | A successful response could not be read or was not what the API returns, such as an upload answered without an asset, or a response was larger than `max_response_bytes:`. |
| `CalmLens::InvalidArgumentError` (`sdk/invalid-argument`, or an `upload/*` code for payload checks the API also makes)                                                       | An argument was rejected before sending.                                                                                                                                   |
| `CalmLens::ConfigurationError` (`sdk/invalid-config`)                                                                                                                        | `ServerClient.new` received an invalid setting, such as a malformed `base_url:`.                                                                                           |
| `CalmLens::WebhookVerificationError` (`sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload`)                                                                        | A webhook delivery failed verification.                                                                                                                                    |

## Retries and timeouts

GET and HEAD requests, and requests that carry an `Idempotency-Key`, are retried up to `max_retries:` times. Uploads always carry a key, reused on every retry, so CalmLens runs them once and replays the saved response; other writes carry one only when you pass `request_options: {idempotency_key: '...'}`. Connection failures, timeouts, `429`, `502`, `503`, `504` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress) are retried with exponential backoff from `retry_base_delay:`, doubling up to 8 seconds, with half of each wait fixed and half random. A `Retry-After` in seconds or as an HTTP date replaces the backoff (`0` retries at once); one over 60 seconds is raised to you instead of waited out. Redirects are never retried or followed. A streamed upload is retried only when its IO can rewind. `cdn.serve` retries like a read and returns the last response.

`timeout:` limits each socket read or write, so large downloads are not cut off. Binary uploads (file content and multipart parts) have no deadline unless you set `upload_timeout:`, which then limits each attempt as a whole; text content follows `timeout:`. A response body over `max_response_bytes:` fails with `sdk/response-too-large`, before it is read when its `Content-Length` says so. See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely).

## Connections

Reuse one client. It keeps up to 8 idle keep-alive connections per host, is safe to share between threads, and never follows redirects, so your API key never reaches another host. After `fork` (Puma, Unicorn, Resque) the child opens its own connections instead of sharing the parent's. Call `close` when finished. Requests identify themselves as `calmlens-ruby/<version> (ruby/<version>)`, and `inspect` never shows the API key.
