# @nitida/sdk — for coding agents

**Read `skills/nitida-sdk/SKILL.md` in this package first.** It is the canonical guide and it
ships inside the tarball, so it is already on disk in `node_modules/@nitida/sdk/skills/`. It
carries the things that cost real incidents to learn — the base36 tenant prefix, the width ladder,
the `original`-preset trap, the object-storage CORS boundary — not just the API shape.

**Then read `src/`.** This package publishes its TypeScript sources (`files: ["dist/**", "src/**"]`),
so the source *is* the reference: every public method carries TSDoc, and the ones worth copying
carry `@example` blocks. Read the source for the canonical, version-pinned answer rather than
recalling an API from training data — this SDK moves.

## The five-minute version

```ts
import { NitidaClient } from "@nitida/sdk/server";   // uploads: server-only, holds the amk_rt_* key
import { getTransformUrl, setCdnBase, setTenantId } from "@nitida/asset-client"; // URLs: anywhere
```

- **Uploading** → `aq.upload(file, { fileName, contentType, presets })`. One call does compress →
  sha256 → presign → direct-to-storage PUT → `/assets/process` → wait-until-ready.
- **Image URLs** → `getTransformUrl({ sha }, { format: "webp", width })`. The width MUST be on
  `TRANSFORM_WIDTHS` or the edge answers **400**.
- **Video URLs** → `getAssetUrl({ sha }, "video")` after `setTenantId(id)`. The path segment is
  **base36** (`tenant 12 → /c/v/…`); a decimal prefix 404s.

## Which entry point exports what — read this before your first import

**`@nitida/sdk`, `@nitida/sdk/server` and `@nitida/sdk/web` all carry the same
surface.** Pick the one that matches your runtime and import everything from it:

```ts
import { NitidaClient, getTransformUrl, getHlsLadder } from "@nitida/sdk/server";
```

The other three subpaths are **additive** — they carry only their own
runtime-specific symbols and you import a complete one alongside:

| subpath | carries | |
|---|---|---|
| `/react` | `NitidaProvider`, `useSlot`, `useSlots`, `useNitidaClient` | + `/web` or `/server` |
| `/native` | `compressImage`, `compressImages` for React Native | + `/web` |
| `/expo` | `createExpoUploader`, `listResumableSessions`, `cancelResumableSession` | + `/web` |

⚠️ **React Native needs two imports.** `/web` for the client and every URL
builder — it has no top-level browser imports, its compressor is a dynamic
`import()` — plus `/native` and/or `/expo`. `/native` on its own gives you a
compressor and no way to build a URL.

The single deliberate omission: **`NitidaClientOptions` is not on `/web`**, because
it carries `apiKey` and this subpath exists so that shape is unreachable from
browser code. Use `WebClientOptions`.

⚠️ **This was not true before 2026-08-21.** `/server` was short **28** of the
root's 72 exports and `/web` short **37** — the whole palette family, every slot
helper, the HLS ladder helpers, the preset constants, and on `/web` even
`setTenantId`. Importing `getHlsLadder` from `/server` failed at **runtime**, not
at compile time. It is now asserted in CI, so if you are reading an older
version of this file, verify against the `.d.ts` you actually installed.

## The four things that bite hardest

1. **`presets` decides what exists forever.** Omit it and you get `original` only. Ask for
   `["thumb"]` and the bytes you PUT are *not* retrievable. A variant not requested at the FIRST
   ingest cannot be added later once the cleanup job reaps `raw/`.
2. **The `amk_rt_*` key is server-only.** The browser talks to *your* route; your server talks to
   the platform. A runtime key in a client bundle is a write key to a paid platform.
3. **The PUT goes browser → object storage directly, so the STORAGE BUCKET answers the CORS
   preflight.** An origin missing from the bucket policy cannot be fixed in this SDK, in your app,
   or by the API's allowed-origins setting — ask us to add it. Symptom: the direct PUT fails with a
   bare network error while every earlier step is green.
4. **Video is not image.** `/assets/process` filters video presets to
   `{poster, video, aiproxy, probe}` — `original` is silently dropped — and a video finalizes
   ASYNCHRONOUSLY: the call answers `{ assetId, status: "processing" }` and a background job flips
   it to `ready` 1–2 min later, so `processAndWait` needs a `timeoutMs` of at least `300_000`.


## Private assets — `visibility`

Default is `"public"`. Set `private` and **every public door answers 404** —
stored variants, the raw original, the HLS ladder, and `/t/` (including the
poster frame of a private video). The bytes come back only through a signed URL
that expires.

```ts
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
// ON YOUR BACKEND, once you decided this viewer may see it:
await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
```

- **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
  DTO before you check storage. It is the number-one support question.
- **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
  `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
  `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
  only when the value you pass says `visibility: "private"`. A DTO that says
  `"public"`, or a bare `{ sha }`, is never refused — and this is a DIFFERENT
  rule from the missing-preset fallback below, which is about presets, not
  privacy.
- **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
- **The signing key is a backend secret** — it mints URLs for every private
  asset the tenant owns.
- **⭐ Where the signing key comes from: the response that CREATED your
  project, once.** `POST /admin/projects` returns `signingKey` next to the
  three API keys, and the console shows it in the same panel. Nothing else
  hands it out — `GET /admin/projects/:code` does **not** include it. If it is
  lost, the only endpoint that returns a key is
  `POST /admin/projects/:code/rotate-signing-key`, which **invalidates every
  URL already signed** and needs the system-scope key the platform operator
  holds. A tenant with nothing signed yet can rotate for free; a live one
  cannot, which is why you save it at creation.
- **⭐ Already have a project and never saw a signing key?** Then you never got
  one — projects created before 2026-08-23 were not handed it, and no endpoint
  shows you the current one. **If you have not signed any URLs yet, ask us to
  rotate: with nothing in flight, rotation invalidates nothing and is free.**
  If you already have signed URLs circulating, ask us for the current key
  instead. This is the wall the T5 agent hit, and the sentence that was
  missing.

## ⚠️ EXIF orientation — two dimension pairs, and mixing them stretches the photo

A phone taking a portrait photo does not store portrait pixels: it writes the
sensor buffer landscape and tags it `Orientation` 5–8. Every such file has two
pairs, transposes of each other — `4032×3024` stored, `3024×4032` displayed.

Stored pair → only orientation-invariant quantities (area, a megapixel budget).
Displayed pair → anything geometric: a resize target, an aspect ratio, a layout
box, a coordinate you denormalise.

Mixing them shipped twice: `@nitida/asset-compressor-web` 0.6.0–0.6.2 stretched
every affected photo **1.78×** (fixed in **0.6.3**), and `asset-manager` recorded
the stored pair as the asset's `w`/`h` until 2026-08-28. Both needed orientation
5–8 plus a long side above the box, and **HEIC was immune** — which is why it
looked random. The damage is pre-upload, so affected photos must be re-uploaded,
not repaired.

Two traps worth naming:

- **`createImageBitmap` with BOTH resize axes does not preserve the ratio.** Pass
  one axis; the spec derives the other and the proportions survive by
  construction.
- **`sharp(x).rotate().metadata()` does NOT apply the rotation** — `.rotate()`
  queues an operation, `.metadata()` reads the input. Measured on sharp 0.34.5:
  it returns `4032×3024` while `.rotate().toBuffer()` returns `3024×4032`.

## Two ways an image gets smaller, and only one of them is yours to call

This is the question every programmatic caller gets wrong, so it is stated flat:

| | who does it | what it shrinks |
|---|---|---|
| **Client compressor** (browser / Expo) | the UI, before the PUT | the **upload**: quality 0.85, max 2880 px, WebP. iPhone 9.1 MB → 1.8 MB |
| **Variants + `/t/`** | the backend, on request | the **delivery**: 19 MB JPEG → 170 kB WebP at 1920 |

**The backend never recompresses the raw. Ever.** That is deliberate: the raw
has to stay pristine so variants are *regenerable* — the day you add AVIF or
raise the max dimension, the pipeline re-runs against it. A client→server lossy
chain bakes artifacts in forever.

So for an API/agent upload there is nothing to "turn on": you were never going
to compress the raw, and delivery is already optimised two ways.

```ts
await aq.upload(file);
aq.transform(asset, { width: 1280, format: "webp" });  // → /t/…, generated once, cached after

// Only for a rung you KNOW will be rendered over and over:
await aq.upload(file, { presets: ["original", "thumb"] });
```

**`presets` defaults to `["original"]`** — a bare upload stores the raw and no
rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
of returning a URL that 404s. You still get optimised bytes; you just pay the
first encode.

**On demand is the default, and it is the right one.** A variant exists because
somebody asked for it; we do not manufacture sizes on the chance that they might
be wanted. Name presets at upload only for a rung you *know* will be rendered
over and over — a card thumbnail on every listing page — where paying the encode
once up front beats paying it once lazily. For everything else, upload bare and
let `/t/` do it.

⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
pixel widths and a transform cannot keep that promise on a source smaller than
the rung, because the pipeline never enlarges. An empty srcSet degrades to `src`; a lying one
degrades to a wrong choice.

## Related packages

| Package | Job |
|---|---|
| `@nitida/asset-client` | URL builders + preset tables. No network, no key. |
| `@nitida/asset-compressor-web` | Browser image compression (worker → OffscreenCanvas → WebP). |
| `@nitida/asset-uploader-web` | Per-file transport: multipart, resume via IndexedDB, progress. |
