# SDK TypeScript

> One package for your backend proxy and your app: timeouts, fallback, consent gate, offline queue.

Source: https://www.patterna.nl/docs/connector-sdk-typescript

`patterna-sdk` is the TypeScript client. Use it in two places: on your backend, which holds the keys, and in your browser or Expo app, which talks to **your** backend proxy and never to Patterna with a key.

It ships ESM and CJS with types, has zero runtime dependencies and needs only `fetch`, `AbortController`, `setTimeout` and, for signing, WebCrypto. It runs on Node 22+, Bun, Deno, edge runtimes and React Native.

## Install

```sh
npm install patterna-sdk@0.4.1
```

`patterna-sdk` 0.4.1 is on npm (unscoped: there is no `@patterna/sdk`), published from the public repository [payam-h/patterna-sdk](https://github.com/payam-h/patterna-sdk) (MIT), subdirectory `typescript/`, tag `v0.4.1`. Pin the version. It works with engine 0.3.1 (`engine_version` on `/health`).

## The client

```ts
import { PatternaClient } from "patterna-sdk";

const patterna = new PatternaClient({
  baseUrl: "https://patterna-api.orven.am/v1",
  keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY },
});
```

| Method | Key | Budget | Retried |
|---|---|---|---|
| `recommend(req)` | serve | 150 ms | never: each call logs an impression |
| `reportInteractions(requestId, interactions)` | serve | 5 s | once |
| `ingestEvents(events)` | ingest | 5 s | once (idempotent on `event_id`) |
| `upsertItems(items)` | ingest | 5 s | once |
| `setItemStatus(kind, id, { status, published_at })` | ingest | 5 s | once |
| `setItemTags(kind, id, tags)` | ingest | 5 s | once |
| `setUserMode(userId, mode)` | admin if set, else ingest | 5 s | once |
| `setUserProfile(userId, profile)` | admin if set, else ingest | 5 s | once |
| `deleteUser(userId)` | admin if set, else ingest | 5 s | once |

Budgets are per attempt. Change them per client with `timeouts: { recommendMs, ingestMs }` or per call with `{ timeoutMs, signal }`. The single retry fires after 100 ms on 5xx and network errors only. Timeouts are not retried: the budget is already spent.

The engine accepts the ingest key for `setUserMode`, `setUserProfile` and `deleteUser`, so a backend needs only a serve key (`pk_s_…`) and an ingest key (`pk_i_…`). Never configure the admin key (`pk_a_…`) in a request path.

## Errors

Every failure is a `PatternaError` with `code`, `op`, `status`, `requestId`, `details` and a `transient` getter. The subclasses:

| Class | When |
|---|---|
| `ApiError` | the API answered with an error; `code` is the API's, e.g. `no_candidates`, `surface_off`, `mode_conflict`, `pii_rejected`. All codes: [Error codes](https://www.patterna.nl/docs/reference-error-codes) |
| `TimeoutError` | the budget ran out |
| `NetworkError` | no answer |
| `ConsentError` | a consent rule was broken; nothing was sent |
| `ValidationError` | bad input, such as an email used as a user id; nothing was sent |

```ts
import { ApiError } from "patterna-sdk";

try {
  await patterna.ingestEvents(batch);
} catch (err) {
  if (err instanceof ApiError) log.warn({ op: err.op, code: err.code, request_id: err.requestId });
  else throw err;
}
```

## Consent gate

The SDK refuses to send a request that breaks the user's consent mode.

- `recommend` throws `ConsentError` (`consent_required`) when `mode` is missing or unknown.
- A `non_profiled` request must carry a `session_id` and no `user_id`. The types forbid it, and at runtime the SDK throws `ConsentError` (`consent_violation`).
- A user id must be the random per-tenant id you mint. Emails and identity-provider subjects (`auth0|...`) are refused with `ValidationError` everywhere a user id goes. The id never appears in the error message.
- Event `context` accepts only `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region`, `subscription_tier`, `tenure_days`.

See [Consent modes](https://www.patterna.nl/docs/consent-modes).

## Fallback

`withFallback` runs the Patterna call and switches to your own list on failure.

```ts
import { withFallback } from "patterna-sdk";

const r = await withFallback(
  () => patterna.recommend({ surface: "home_articles", mode, user_id, context: { locale: "nl" } }),
  () => newestArticleIds(),            // your current query
  { onFallback: (e) => metrics.inc("patterna_fallback", { code: e.code }) },
);
const ids = r.source === "patterna" ? r.value.items.map((i) => i.item_id) : r.value;
```

- It falls back on any SDK error except `ConsentError`, `ValidationError`, `pii_rejected` and `mode_conflict`. Those are bugs in your code, and a consent bug is a privacy bug, so they are rethrown.
- An `ApiError` also falls back. Check its `code` in `onFallback`: `surface_off` (409) and `no_candidates` (422) are expected while a surface is off or empty; `bad_request` (400, also for any unknown JSON field), `unauthorized`/`wrong_scope` (401), and `unknown_surface` (404) are bugs to alert on, even though the user sees your list.
- `{ on: "timeout" }` narrows the fallback to timeouts.
- Report interactions only when `r.source === "patterna"`. A fallback list has no request id.
- Report each item with its `kind` (copy it from the slate item) and its 0-based `position`. The engine takes `kind` and the served position from the logged slate anyway and refuses item ids that were not in it (`422 unknown_item`).
- A slate with `shown: false` (fewer items than the surface's `min_slots`) means: hide the block. Do not call `abandon` for it; the engine has already logged it as not shown (abandon answers `409 not_shown`). Such a slate carries no items and stays in its arm counted as served with zero taps.
- `withFallback` does not report abandonment. On a backend, prefer `patterna.recommendWithFallback(req, fallback)`, which does (see below).

## Backend proxy

Your backend authenticates the user, swaps the account id for the Patterna id it minted, applies the user's consent and forwards. The proxy paths mirror Patterna's, so the same SDK works on both sides.

```ts
import { Hono } from "hono";
import { PatternaClient, withFallback, ValidationError, ConsentError, type Event } from "patterna-sdk";

const patterna = new PatternaClient({
  baseUrl: process.env.PATTERNA_URL!,
  keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY },
});
const app = new Hono<{ Variables: { account: Account } }>();
app.use("/patterna/*", requireSession); // sets c.var.account

app.post("/patterna/recommendations", async (c) => {
  const body = await c.req.json();
  const { mode, patternaId } = await consentFor(c.var.account); // your table; missing = non_profiled
  const req =
    mode === "non_profiled"
      ? { surface: body.surface, mode, session_id: body.session_id, context: body.context }
      : { surface: body.surface, mode, user_id: patternaId, context: body.context };
  // fallbackSlate(): your current list as a Slate with `fallback: true` and an empty request_id,
  // so the app renders one shape and skips reporting interactions for it.
  const r = await withFallback(() => patterna.recommend(req), () => fallbackSlate(body.surface));
  return c.json(r.value);
});

app.post("/patterna/events", async (c) => {
  const { events } = (await c.req.json()) as { events: Event[] };
  const { mode, patternaId } = await consentFor(c.var.account);
  // Never trust identity from the client: set it here.
  const owned = events.map((e) =>
    mode === "non_profiled" ? { ...e, user_id: undefined, source: "backend" as const }
                            : { ...e, user_id: patternaId, session_id: undefined, source: "backend" as const });
  try {
    return c.json(await patterna.ingestEvents(owned), 202);
  } catch (err) {
    if (err instanceof ValidationError || err instanceof ConsentError) return c.json({ code: err.code }, 400);
    throw err;
  }
});
```

`Account`, `requireSession`, `consentFor` and `fallbackSlate` are yours. Set `PATTERNA_URL` to `https://patterna-api.orven.am/v1`.

## Expo app

The app talks to your proxy, authenticated as the signed-in user. It holds no Patterna key.

```ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import "react-native-get-random-values"; // crypto.getRandomValues for newSessionId on Hermes
import { AppState } from "react-native";
import { EventQueue, PatternaClient, newSessionId, withFallback } from "patterna-sdk";

const patterna = new PatternaClient({
  baseUrl: `${config.apiUrl}/patterna`,
  headers: async () => ({ authorization: `Bearer ${(await getValidTokens())?.accessToken ?? ""}` }),
  timeouts: { recommendMs: 400 }, // a phone round trip to your proxy, which keeps its own 150 ms
});

export const outbox = new EventQueue({
  client: patterna,
  storage: AsyncStorage,         // persist / restore across app launches
  maxSize: 500,
  onDrop: (reason, n) => track("patterna_events_dropped", { reason, n }),
});
let sessionId = newSessionId();  // rotate per launch

export async function onConsentLoaded(mode: "personalized" | "cohort" | "non_profiled") {
  await outbox.setMode(mode);    // nothing is queued before this; non_profiled purges named events
}

AppState.addEventListener("change", (s) => {
  if (s === "active") sessionId = newSessionId();
  else void outbox.flush();      // send before the OS suspends the app
});

export async function homeArticles(fallbackIds: string[]) {
  return withFallback(
    () => patterna.recommend({ surface: "home_articles", mode: "non_profiled", session_id: sessionId, context: { locale: "nl", platform: "ios" } }),
    () => fallbackIds,
  );
}

export function seen(requestId: string, itemId: string, position: number) {
  return outbox.enqueue({
    event_id: `${requestId}:${itemId}:seen`,  // idempotency key: one seen per slot
    type: "rec_seen", occurred_at: new Date().toISOString(), source: "sdk",
    session_id: sessionId, request_id: requestId, surface: "home_articles",
    props: { item_id: itemId, kind: "document", position }, // kind from the slate item; position from 0
  });
}
```

Only report when the user allowed usage analytics. Otherwise skip `seen` and pass `context.measured: false` on the request, so the slate is left out of the ledger: see [Consent modes](https://www.patterna.nl/docs/consent-modes#measured-lists-and-analytics-consent).

The proxy sets the real identity. So the app sends `session_id` only and stays correct whatever the user's mode is.

## Offline queue

`EventQueue` is a bounded outbox for events sent from the app.

- **Bounded.** `maxSize` caps it. When it overflows, `onDrop(reason, n)` tells you how many events went.
- **Persistent.** Pass an AsyncStorage-shaped `storage` and the queue survives app restarts. Restoring is idempotent.
- **Consent-aware.** Nothing is queued until `setMode` is called. Switching to `non_profiled` purges events that name a user.
- **Idempotent.** Events keep their `event_id`, so a resend after a crash is counted once.
- Call `flush()` when the app goes to the background.

## Signing

```ts
import { signEvent } from "patterna-sdk";

// The key is the pss_ secret string.
const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!);
```

The signature is lowercase hex HMAC-SHA256 over the canonical JSON of the event without `signature`: keys sorted, no whitespace, `undefined` dropped, U+2028 and U+2029 escaped. The HMAC key is the `pss_` secret string's UTF-8 bytes. Since engine 0.2 secrets are `pss_` strings; a secret from before that (64 hex characters, no prefix) no longer verifies, so rotate it once. Events from your proxy with an ingest key are trusted without a signature anyway, so most backends never sign. Sign on a backend only. On React Native without `crypto.subtle`, pass a SubtleCrypto implementation as the third argument. Test vector: [Backend API](https://www.patterna.nl/docs/connector-backend-api#signing-events).

## Deadline, abandonment and the rest of the client

The same features as the [Go SDK](https://www.patterna.nl/docs/connector-sdk-go#deadline-abandonment-and-the-rest-of-the-client), in TypeScript shape:

- `recommend` sends `X-Deadline-Ms` (120 ms by default, below the 150 ms budget) and an `X-Request-Id` it minted.
- `recommendWithFallback(req, fallback)` reports a timeout, 5xx or network failure with `abandon(requestId, reason)` in the background, so the engine flags the slate `client_abandoned`; it stays in its arm and counts in the fallback rate. Call `abandon` yourself when you drop a slate that came back shown. See [Timeouts and the ledger](https://www.patterna.nl/docs/timeouts-and-the-ledger).
- `recommendMany([{ request, fallback }, …])` for several surfaces on one screen, under one deadline.
- `assignmentUnit(secret, userId)` for `assignment_unit` (64 hex characters, `non_profiled` only), `context.measured`, the interaction field `block`, and the call option `{ tester: true }` (`X-Patterna-Tester: 1`).
- `getItem(kind, id)` reads one item with its `attributes`, `tags`, `status` and `enrichment` (ingest key; `item_not_found` when there is none under that id and kind); `listItems`, `itemsChangedSince` and `deleteItem` for catalog reconciliation.
- Admin methods on the same client, with an admin key: `listSurfaces`, `setSurfaceStatus`, `setSurfaceTesters`, `createFlow`, `planFlow`, `applyFlow`, `activateFlow`, `rollbackFlow`, `startExperiment`, `endExperiment`, `ledger`, `exportLedger`, `createSigningSecret`, `rotateSigningSecret`. The `patterna` CLI ships with the [Go SDK](https://www.patterna.nl/docs/connector-sdk-go).

## Control lists, communities and the context fields

SDK 0.4.1 types `context.control_item_ids` (your own editorial list for the page, served to the control arm) and `context.member_of` (communities the user joined, left out in every mode), and reads `fill_count`, `fill`, `control_source` and `shown` from the slate. See [Backend API](https://www.patterna.nl/docs/connector-backend-api#serve-recommend-and-report).

The engine reads three more context fields: `weekday` (0 = Monday, from the device clock), `subscription_tier` and `tenure_days`. SDK 0.4.1 types them, together with the catalog `attributes` the engine reads, builders for the new events, the flow filters and generators of engine 0.3.1, and the reason key `recommended`. What each field does: [What the engine uses, and what it refuses](https://www.patterna.nl/docs/signals).

## Left out on purpose

- A circuit breaker. Backends that need one use the [Go SDK](https://www.patterna.nl/docs/connector-sdk-go); in an app, `withFallback` and the per-call budget are enough.
