patterna-sdk
v0.4.1
Published
Patterna client for backends, browsers and React Native: recommendations, interactions, events and catalog.
Readme
patterna-sdk
Version 0.4.1 (see CHANGELOG.md): the production engine reports engine_version: 0.3.1 on GET /v1/health (the contract
version), and every field in this release is live on it. Works against engine 0.2 or later;
older engines ignore the fields they do not know.
Install. The npm package is patterna-sdk (unscoped); pin the version:
npm install [email protected]The source is the public repository payam-h/patterna-sdk,
directory typescript/, tag v0.4.1.
TypeScript client for the Patterna API. One package for three places:
- a backend (Node 22+, Bun, Deno, edge runtimes) holding the scoped API keys;
- a browser or React Native / Expo app talking to your backend proxy, never to Patterna with a key;
- tooling and tests, with the contract types generated from the OpenAPI contract (https://patterna.orven.am/docs/openapi.yaml).
ESM + CJS, TypeScript strict, zero runtime dependencies. Only fetch, AbortController,
setTimeout and, for signing, WebCrypto. No Node built-ins in the core (eslint enforces it).
Fifteen-minute tour
In a checkout of typescript/:
npm ci
npm test # vitest, mocked fetch, ~0.3 s
npm run check # tsc --noEmit + eslint + vitest + tsup build| File | What |
|---|---|
| src/client.ts | PatternaClient: every serve and ingest method, input checks |
| src/http.ts | fetch wrapper: timeout, one retry for idempotent calls, typed errors, request ids |
| src/consent.ts | the consent gate and the opaque-user-id check |
| src/queue.ts | EventQueue: bounded, persistent offline outbox |
| src/fallback.ts | withFallback |
| src/signing.ts | canonical JSON and HMAC-SHA256 event signatures |
| src/generated/types.ts | generated by make contracts; never edit |
The client
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 },
traceId: () => currentTraceId(), // optional: X-Trace-Id on every call
});| Method | Scope / 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 key if set, else ingest | 5 s | once |
| setUserProfile(userId, profile) | admin key if set, else ingest | 5 s | once |
| deleteUser(userId) | admin key if set, else ingest | 5 s | once |
| recommendWithFallback(req, fallback), recommendMany(reqs) | serve | 150 ms | never |
| abandon(requestId, reason) | serve | 5 s | once |
| getItem, deleteItem, listItems, itemsChangedSince | ingest | 5 s | once |
| createSigningSecret and the admin methods below | admin | 30 s | idempotent ones once |
Budgets are per attempt and can be changed per client (timeouts: { recommendMs, ingestMs,
adminMs }) or per call ({ timeoutMs, signal }). The single retry fires on 5xx and network
errors only, after retryDelayMs (100 ms); timeouts are not retried because the budget is
already spent.
Per-call options also take requestId (sent as x-request-id; minted otherwise), traceId
(x-trace-id, overriding the client's traceId()) and, on recommend, tester: true
(x-patterna-tester: 1).
Deadlines, request ids and abandonment
recommendsendsx-deadline-ms: 120 ms by default (timeouts.recommendDeadlineMs, always belowrecommendMs; the engine's own default budget is 120 ms too), so the engine serves its own fallback in time instead of answering after the client gave up. A shortertimeoutMsshrinks it by the same margin:timeoutMs: 80sends 50.- The client mints the request id, so it knows it even when no slate comes back.
recommendWithFallbackuses that: on a timeout, network error, 5xx or unreadable answer it sendsPOST /recommendations/{id}/abandonin the background, and the impression counts as a client fallback instead of a slate with zero taps. A 4xx (nothing was logged) is not abandoned. Failures of that background call go toonBackgroundError.
const r = await patterna.recommendWithFallback(req, () => newestArticleIds(), { traceId });
if (r.source === "patterna") render(r.value.items);
else { render(r.value); log.info({ request_id: r.requestId, code: r.error.code, abandoned: r.abandoned }); }Call patterna.abandon(requestId) yourself when a slate arrived but the app showed something
else.
shown: false: hide the block (engine 0.3)
When fewer items than the surface's min_slots pass the request's rules, the engine answers
shown: false with no items. That slate already counts in its arm with zero taps, so it is never
abandoned and never replaced by your list. Every result carries shown:
const r = await patterna.recommendWithFallback(req, () => newestArticleIds());
if (!r.shown) hideBlock(); // source "patterna", no items, nothing abandoned
else render(r.source === "patterna" ? r.value.items : r.value);abandon(requestId) on such a slate rejects with not_shown without sending when this client
served it; otherwise the engine answers 409 not_shown. withFallback results carry shown too.
Control lists, communities and fills (engine 0.3)
context.control_item_ids(at most 50): your own editorial list for this page (the article's "pairs with" products). In the control arm it is the slate, in this order (control_source: "request"); in treatment it is what short slates are filled from. Without it the control slate comes from the surface'sfallback.by_anchorfor the anchor ("by_anchor") or its own list ("surface").context.member_of(at most 500): community ids the user is in, never offered. Meant fornon_profiledrequests, which have no identity to look membership up by.fill_counton the slate: trailing slots filled from the editorial list because the ranked candidates ran out. The SDK marks those itemsfill: true(markFills).model_versionsis typed onSlate; engine 0.3 logs it on the impression (trace, export) and does not send it with the answer yet.
AI coach products (engine 0.3)
import { coachProductShown, coachProductTapped } from "patterna-sdk";
await patterna.ingestEvents([
coachProductShown({ mode, userId, productId: "p_mag", conversationId, position: 0 }),
]);
// non_profiled: { mode: "non_profiled", sessionId, ... } and no userIdproduct_id is the catalog external_id, conversation_id your own opaque id (never message
text), position the 0-based place among the answer's products. The helpers apply the consent
rules (user id in personalized and cohort, session id only in non_profiled) and default
event_id, occurred_at and source: "backend".
Signals: context, editorial attributes, activity events (engine round 5)
Everything the engine reads is listed at https://patterna.orven.am/docs/signals; these are the SDK's parts of it.
import { timeContext, TIER_FREE } from "patterna-sdk";
await patterna.recommend({
surface: "home_articles", mode: "personalized", user_id,
context: {
locale: "nl",
...timeContext(new Date(), user.timeZone), // hour_bucket and weekday (0 = Monday) in the user's zone
subscription_tier: TIER_FREE, // "premium" or your own tier name (<= 32 chars)
tenure_days: daysSinceSignup,
region: "Noord-Holland", // one of the tenant's regions; never a location
session_items: recentlyOpenedIds, // most recent first, at most 20
},
});Without a time zone timeContext reads the device clock. Out-of-range values fail before the
request leaves; the same keys are allowed (and checked) in an event's context. For free the
engine applies the premium policy and the paywall cooldown; none of these is stored on the user.
Editorial attributes. editorial on an upsert is the typed form of attributes:
await patterna.upsertItems([{
external_id: "p_magnesium", kind: "product", title: "Magnesium", locale: "nl",
editorial: { format: "product", available: inStock, repurchase_days: 30, price_cents: 2495,
currency: "EUR", image_url, url: shopUrl, product_type: "supplement" },
}]);The engine reads format, duration_min/reading_time_min, difficulty, author,
available (false is never served), expires_at, starts_at (events; strings in RFC 3339 or
Date), repurchase_days, region and phase_fit; the panel shows price_cents,
compare_at_cents, currency, image_url, url, product_type and categories. A typed key
wins over the same key in attributes, which keeps any others. region is sent as the upsert's
top-level region, the field the engine validates and ranks on.
Products from a Shopify store do not need this call: the jobs CLI syncs the public storefront
(python -m patterna_jobs shopify-storefront --tenant <slug> --shop-url https://<shop>) and
turns an orders export into co-purchase pairs (orders-csv --csv <file>). There is no admin API
call for either; run them where the jobs run.
Activity events, each with the envelope fields of EventBase (mode, userId,
sessionId, surface, requestId, occurredAt, eventId, source, context):
communityPostCreated({ mode, userId, postId, communityId });
communityReply({ mode, userId, postId, communityId });
communityReaction({ mode, userId, postId, communityId, reaction: "heart" });
paywallViewed({ mode, userId, itemId, requestId }); // requestId: the slate it came from
pushOpened({ mode, userId, itemId, notificationType: "weekly_digest" });
searchPerformed({ mode, sessionId, queryHash, resultCount, clickedItemId });
itemEngaged({ mode, userId, itemId, engagedSeconds: 42, maxScrollPercent: 80 });
itemCompleted({ mode, userId, itemId, kind: "document", collection: "workouts" });
orderPlaced({ mode, userId, orderIdHash, productIds, valueCents: 4990, currency: "EUR" });Identity follows consent: personalized and cohort carry the user id (required) and the
session id when given; non_profiled carries the session id only and a user id is refused.
queryHash must be 16 to 128 lowercase hex characters (hash the query yourself, e.g.
HMAC-SHA256 with a key you hold), so query text cannot travel by mistake. item_completed and
order_placed are ranking events: signEvent them when they do not come from an ingest key.
Reasons. A slot the engine cannot truthfully explain says REASON_RECOMMENDED
("recommended", "Aanbevolen"/"Recommended"); REASON_KEYS lists every key. Treat an unknown
key like recommended.
Flows. FlowConfig carries the generators time_fit and format_fit (silent: they move
scores and never name a reason; GENERATOR_KINDS) and the filters exclude_dismissed_days,
exclude_consumed_days, paywall_cooldown_days, repurchase_days, hide_unavailable and
relevance_floor (type FlowFilters). validateFlowConfig checks a config as the engine does
on save, plus unknown filter keys; createFlow, planFlow and applyFlow run it first.
Several surfaces in one call
recommendMany([{ request, fallback }, ...]) runs the requests in parallel; each has the same
budget from the same start, so Home's articles and products cost one budget, not two. Each falls
back and is abandoned on its own and is its own impression and ledger row. Use it when the
placements are separate on screen; one surface with mixed candidate_kinds gives one list and one
ledger row you cannot split later.
Interactions: kind, re-sorting and split blocks
kindis required for anything that is not a document. For slates this client served, an interaction withoutkindon a community, product, event or tip throwsValidationErrorbefore sending.interactionFor(slate, itemId, type)fillskindandposition.displayed_position: when the app re-sorts on the device, keeppositionas the served slot and setdisplayed_positionto where it was shown.block: when one slate is split across UI blocks, name the block. A slate of 7 shown as a top slot plus an end carousel of six: requestslots: 7, renderitems[0]on top anditems.slice(1)in the carousel, and report with the served positions:
const s = r.value; // a Slate
const report = [
interactionFor(s, s.items[0]!.item_id, "seen", { block: "top" })!, // position 0
...s.items.slice(1).map((it) => interactionFor(s, it.item_id, "seen", { block: "end_carousel" })!), // 1..6
];
await patterna.reportInteractions(s.request_id, report);Report seen for carousel slots only when they scroll into view. One slate keeps one
impression and one propensity per slot.
Consent: measured and assignment unit
measuredContext(context, analyticsConsent)setsmeasured:falsekeeps the request out of the ledger and the training data; the slate is still ranked and shown.assignment_unitkeeps one non-profiled person in one holdout arm across rotating sessions:assignment_unit: await assignmentUnit(experimentSecret, yourUserId)on the backend. The secret never leaves you; only the 64-hex shape is accepted, so a raw id cannot slip through. Same function and test vector in the Go and Python SDKs.
Catalog reconciliation
const item = await patterna.getItem("product", "42"); // ingest key; item.attributes, item.tags, item.status, item.enrichment
for await (const item of patterna.itemsChangedSince(lastRun)) reconcile(item);
await patterna.deleteItem("community", "community_42");A missing item, or one stored under another kind, rejects with item_not_found
(isApiError(err, ApiErrorCode.ItemNotFound)); an archived one comes back with status: "archived".
Admin (admin key)
For CI scripts that keep flow configs in your repository: listSurfaces, getSurface,
setSurfaceStatus, setSurfaceTesters, listFlows, getFlow, createFlow, activateFlow,
rollbackFlow, planFlow, applyFlow(surface, config, { note, activate }),
listExperiments, startExperiment, endExperiment, ledger, ledgerWeeks,
exportLedger, exportStatus, createSigningSecret, rotateSigningSecret.
applyFlow compares configs as canonical JSON: nothing happens when the active version
matches. The Go SDK ships the same as a CLI (patterna, in the go/ module).
Engine 0.3:
applySurface(spec)/planSurface(spec)/updateSurface(key, spec)with aSurfaceSpec(slots,item_kinds,fallback: { kind, item_ids, by_anchor },min_slots,device_resort,notes,exploration_share).applySurfacepatches only the fields that differ and returns{ changes, applied }; the fallback is replaced as a whole.validateSurfaceSpecruns the engine's checks before anything is sent. The engine answers a surface PATCH with{ key, ok }, so these calls (andsetSurfaceStatus,setSurfaceTesters) read the surface back.ledgerByBlock(q)(by=block): one row per surface and block;ledger({ block })one block.surfaceMetrics(key, { days, block }): daily rows withnot_shown_shareandclient_abandoned_rate.getSettings(),updateSettings(patch),setPhaseTags(on)(phase_fitin slates; off by default).health():{ status, version, engine_version, ... }.
Errors
Every failure is a PatternaError with code, op, status, requestId, details and a
transient getter. Subclasses: ApiError (the API answered; code is the API's, e.g.
pii_rejected), TimeoutError, NetworkError, ConsentError, ValidationError.
ApiErrorCode names the codes callers act on; test them with isApiError(err, code):
| Code | Status | In withFallback / recommendWithFallback |
|---|---|---|
| no_candidates | 422 | fallback (nothing eligible) |
| surface_off | 409 | fallback (the kill switch) |
| unknown_surface | 404 | fallback, but a configuration bug: alert on it |
| mode_conflict | 422 | rethrown |
| mode_required | 422 | rethrown; setUserProfile without mode for a user Patterna has no row for |
| not_shown | 409 | abandon on a shown: false slate: hide the block instead |
| pii_rejected | 422 | rethrown |
| unknown_request | 404 | abandon, reportInteractions |
| unknown_item | 422 | reportInteractions: an item that was not in the slate served for that request (details.unknown_item_ids) |
| item_not_found | 404 | getItem, deleteItem, setItemStatus, setItemTags: no item with that id and kind |
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
recommendthrowsConsentError(consent_required) whenmodeis missing or unknown.- A
non_profiledrequest must carry asession_idand nouser_id; the type forbids it and the runtime throwsConsentError(consent_violation) for untyped callers. Nothing is sent. - User ids must be the random per-tenant id you mint. Emails and identity-provider subjects
(
auth0|...) are refused withValidationErroreverywhere a user id goes; the id is never put in the error message. - Event
contextaccepts onlylocale,app_version,platform,hour_bucket.
Fallback
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;By default it falls back on any SDK error except ConsentError, ValidationError,
pii_rejected, mode_conflict and mode_required, which are bugs in the caller (a consent bug is a privacy bug)
and are rethrown. Prefer client.recommendWithFallback, which also reports abandoned slates. { on: "timeout" }
narrows it to timeouts. Report interactions only when r.source === "patterna".
Signing
import { signEvent } from "patterna-sdk";
const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!);Signature = lowercase hex HMAC-SHA256 over the canonical JSON of the event without signature:
keys sorted, no whitespace, undefined dropped, U+2028/U+2029 escaped. The key is the UTF-8
bytes of the secret string exactly as the API returned it (createSigningSecret, the panel);
never hex-decode it. The Go and Python SDKs produce the same bytes; the suites share the vector
8baf7299…aac9 with key test-signing-key. Sign on a backend; a secret in an app bundle is a
public secret. On React Native without crypto.subtle, pass a SubtleCrypto implementation as
the third argument.
Example: backend proxy
The app never holds a Patterna key. Your backend authenticates the user, swaps the account id for the Patterna id it minted, applies the user's consent, and forwards. Paths mirror Patterna's so the same SDK works on both sides.
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;
}
});Example: Expo app
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";
// Talks to YOUR backend proxy above, authenticated as the signed-in user. No Patterna key here.
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[]) {
const r = await withFallback(
() => patterna.recommend({ surface: "home_articles", mode: "non_profiled", session_id: sessionId, context: { locale: "nl", platform: "ios" } }),
() => fallbackIds,
);
return r;
}
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, position },
});
}The proxy sets the real identity, so the app sends session_id only and stays correct whatever
the user's mode is.
Status
Built: the full serve and ingest client, the admin methods, client-minted request ids with
abandonment, the server deadline header, trace ids, typed errors with the API code and request
id, per-call timeouts (150 ms / 5 s / 30 s), one retry for idempotent calls, the consent gate in types and at
runtime, opaque-user-id checks, withFallback, EventQueue (bounded, persistent via an injected
AsyncStorage-shaped store, consent-aware, idempotent restore), HMAC signing with a cross-language
vector, assignment units, session ids, and engine 0.3: control lists, member_of,
control_source, fills, shown on every result, mode_required and not_shown, coach product
events, surface apply, block ledger rows, surface metrics, phase tags and health; and engine
round 5: the coarse request context with timeContext, typed editorial attributes, nine
activity-event builders, the recommended reason key and validateFlowConfig with time_fit,
format_fit and the six new filters. 185 tests with
mocked fetch and a small in-memory engine. ESM + CJS + d.ts via tsup.
Deliberately left out:
- Admin endpoints beyond surfaces, flows, experiments, ledger, metrics, phase tags and the signing secret (keys, curation, the other settings): the panel owns them.
- A circuit breaker: backends that need one use the Go SDK; in an app
withFallbackand the per-call budget are enough. - No persistent queue for abandonments: they are fire-and-forget promises. In a backend that
shuts down, a few may be lost; the Go SDK drains its queue on
Close.
Settled since 0.1: the user endpoints (/users/{id}/mode, /users/{id}/profile,
DELETE /users/{id}) accept the ingest key (the SDK uses the admin key when configured, else the
ingest key), and the engine verifies signatures over the canonical form above, as the events
contract describes (https://patterna.orven.am/docs/reference-event-types).
