Patterna Docs

Integrate with your AI agent

A self-contained brief a coding agent can execute end to end, with a check after every call.

Developers 7 min read View as Markdown

This page is written to be handed to a coding agent as is. It lists the calls in order, what a correct answer looks like after each one, and the mistakes to avoid. A human can follow it the same way. Run every call against https://patterna-api.orven.am/v1 and stop at the first failed check.

Rules the agent must follow#

  1. Never send personal data. No email, name, IP, device id, free text, coordinates or health values, anywhere. User ids are random ids you mint, never an email or an identity-provider subject like auth0|....
  2. non_profiled requests carry no user_id. They carry a session_id only. Sending both is a privacy bug, not a style issue.
  3. Impressions are logged server-side. Every POST /recommendations writes an impression row before it answers. Never call it to "test" from a loop, never retry it, and never call it for a list you will not show (use "shadow": true for that).
  4. Keys stay on the backend. No Patterna key and no signing secret in an app bundle or in client-side code.
  5. Report the served position and the kind. Use the position from the slate (positions start at 0: items[0] is position 0), even if the app re-sorts on the device, and the item's kind from the slate.
  6. Quote the request id. Every response carries X-Request-Id. Log it with every failure.

Prerequisites#

  • An admin key for the tenant, created once by a person in the panel (Connectors or Governance). The secret is shown once. It starts with pk_a_; serve keys start with pk_s_, ingest keys with pk_i_.
  • A shell with curl and jq.
  • These variables:
Shell
export PATTERNA_API="https://patterna-api.orven.am/v1"
export PATTERNA_ADMIN_KEY="paste-the-admin-secret-here"
  • A random user id to test with. Mint it; never derive it from an account id or email:
Shell
export TEST_USER="u_$(openssl rand -hex 16)"
echo "$TEST_USER"

Create a serve key and an ingest key#

curl
curl -sS -X POST "$PATTERNA_API/keys" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "backend serve", "scope": "serve", "environment": "production"}'
curl
curl -sS -X POST "$PATTERNA_API/keys" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "backend ingest", "scope": "ingest", "environment": "production"}'

Check. Status 201. The body has scope as requested, a prefix, revoked_at: null and a secret. The secret is returned only here. Store both:

Shell
export PATTERNA_SERVE_KEY="paste-the-serve-secret"
export PATTERNA_INGEST_KEY="paste-the-ingest-secret"

Create a surface in shadow#

Skip this if the surface already exists (GET /surfaces).

In shadow your backend calls Patterna on every render exactly as it will in production, the engine logs the impression, and your app shows its own list instead of the answer. A feature flag in your backend decides what is shown, not whether Patterna is called: with the flag off, call Patterna and discard the slate, so shadow produces impressions you can check.

curl
curl -sS -X POST "$PATTERNA_API/surfaces" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "home_articles",
    "name": "Home articles",
    "item_kinds": ["document"],
    "slots": 6,
    "fallback": {"kind": "newest"},
    "exploration_share": 0.2,
    "status": "shadow"
  }'

Check. Status 201, status is shadow.

Give it a first flow and activate it with a note:

curl
curl -sS -X POST "$PATTERNA_API/surfaces/home_articles/flows" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "note": "First flow",
    "config": {
      "generators": [
        {"kind": "trending", "weight": 0.5},
        {"kind": "content_affinity", "weight": 0.5}
      ],
      "ranker": {"kind": "linear"},
      "exploration": {"kind": "plackett_luce", "temperature": 0.7, "share": 0.2}
    }
  }'

Check. Status 201, the body has a version and activated_at: null. Use that version:

curl
curl -sS -X POST "$PATTERNA_API/surfaces/home_articles/flows/1/activate" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"note": "First flow for shadow traffic"}'

Check. Status 200. GET /surfaces shows active_flow_version: 1 for home_articles.

The note is optional and lands in the audit log. If the tenant's two-person rule is on and the surface is live, activation answers 409 approval_required; a person approves it in the panel.

Load the catalog#

Send up to 500 items per call. Send only editorial fields. text is used for tagging and never returned.

curl
curl -sS -X PUT "$PATTERNA_API/catalog/items" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "external_id": "doc_1042",
        "kind": "document",
        "collection": "recipes",
        "title": "Magnesium-rich breakfast bowl",
        "locale": "nl",
        "status": "published",
        "premium": false,
        "published_at": "2026-09-30T08:00:00Z",
        "text": "A breakfast bowl with oats, seeds and cocoa."
      },
      {
        "external_id": "doc_1043",
        "kind": "document",
        "collection": "insights",
        "title": "Sleep and magnesium",
        "locale": "nl",
        "status": "published",
        "premium": true,
        "published_at": "2026-10-02T08:00:00Z"
      }
    ]
  }'

Check. Status 200, upserted equals the number of items sent. The call is idempotent: running it again changes nothing. Then:

curl
curl -sS "$PATTERNA_API/catalog/items?kind=document&limit=10" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY"

Check. Both ids appear in items.

Set the user's mode and profile#

Do this only for a user who opted in. For everyone else skip to step 5 with non_profiled.

curl
curl -sS -X PUT "$PATTERNA_API/users/$TEST_USER/mode" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "personalized"}'
curl
curl -sS -X PUT "$PATTERNA_API/users/$TEST_USER/profile" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "personalized",
    "premium": false,
    "goals": ["better-sleep"],
    "activity_level": "moderate",
    "stress_level": "high",
    "locale": "nl"
  }'

Check. Both return 200. goals must be tags from the tenant taxonomy; better-sleep is an example. Profile fields are declared attributes only, never health values.

These calls take the ingest key: your backend owns consent. An admin key works too.

Ask for a slate#

Personalized:

curl
curl -sS -X POST "$PATTERNA_API/recommendations" \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"surface\": \"home_articles\",
    \"mode\": \"personalized\",
    \"user_id\": \"$TEST_USER\",
    \"context\": {\"locale\": \"nl\", \"platform\": \"ios\", \"hour_bucket\": 8},
    \"slots\": 6
  }" | tee /tmp/slate.json

Non-profiled (no user_id, a session_id instead):

curl
curl -sS -X POST "$PATTERNA_API/recommendations" \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "home_articles",
    "mode": "non_profiled",
    "session_id": "s_7f3a9c2e41d84b0f",
    "context": {"locale": "nl", "platform": "ios"}
  }'

Check. Status 200. The body has a non-empty request_id, surface, arm (control, treatment or none), policy_version, and items. Every item has item_id, kind, position, reason_key and a reason map with the locale you need. position counts from 0. fallback: true means the safety net answered (budget or storage trouble, or no active flow); that is valid, not an error. A control slate is the surface's editorial list with fallback: false. Error codes to expect: 404 unknown_surface (wrong key), 409 surface_off, 422 mode_conflict (a user_id in non_profiled), 422 no_candidates (no published item of the surface's kinds and locale), 400 for any unknown field.

Budget: call this with a 150 ms timeout from your backend and show your own list on timeout. Do not retry. Send a UUID you mint as X-Request-Id and your remaining budget as X-Deadline-Ms (the engine's own budget is 120 ms), and after a timeout call POST /recommendations/{request_id}/abandon (the SDKs do all three for you). The engine refuses interactions for items that were not in the slate (422 unknown_item). A slate with shown: false is already logged as not shown: render nothing and report nothing for it.

Report what the user saw and did#

Echo the request_id, the served position and the item's kind:

curl
REQUEST_ID=$(jq -r .request_id /tmp/slate.json)
ITEM_ID=$(jq -r '.items[0].item_id' /tmp/slate.json)
ITEM_KIND=$(jq -r '.items[0].kind' /tmp/slate.json)
curl -sS -X POST "$PATTERNA_API/interactions" \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"request_id\": \"$REQUEST_ID\",
    \"interactions\": [
      {\"item_id\": \"$ITEM_ID\", \"kind\": \"$ITEM_KIND\", \"position\": 0, \"type\": \"seen\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"},
      {\"item_id\": \"$ITEM_ID\", \"kind\": \"$ITEM_KIND\", \"position\": 0, \"type\": \"click\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}
    ]
  }"

Check. Status 202. Up to 50 interactions per call. A 404 unknown_request means the request_id has no impression. Send kind from the slate for your own logs; the engine ignores it and takes the kind and the served position from the logged slate. Never report interactions for a fallback list your own backend produced: it has no request id.

Send events#

Events from your backend with the ingest key are trusted and marked signed; no signature needed. event_id is the idempotency key; derive it from the source id.

curl
curl -sS -X POST "$PATTERNA_API/events" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"events\": [
      {
        \"event_id\": \"completed:doc_1042:$TEST_USER:2026-10-13\",
        \"type\": \"item_completed\",
        \"occurred_at\": \"2026-10-13T09:30:00Z\",
        \"user_id\": \"$TEST_USER\",
        \"source\": \"backend\",
        \"context\": {\"locale\": \"nl\", \"platform\": \"ios\"},
        \"props\": {\"item_id\": \"doc_1042\", \"collection\": \"recipes\"}
      }
    ]
  }"

Check. Status 202 with accepted: 1, duplicates: 0, rejected: []. Send the same call again: accepted: 0, duplicates: 1. dropped_fields lists props or context keys outside the allowlist; they were not stored. Remove them, do not rename them. An event in rejected has a reason such as unknown type or user_id or session_id malformed.

Verify with the trace#

curl
curl -sS "$PATTERNA_API/users/$TEST_USER/trace?limit=5" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Check. The newest trace has the request_id from step 5, mode: "personalized", the arm, and a slate where the item you clicked has a propensity, reason_key, generator and outcome: "click". If the outcome is none, the interaction did not match: compare request_id, item_id and position.

Clean up the test user#

curl
curl -sS -X DELETE "$PATTERNA_API/users/$TEST_USER" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY"

Check. Status 202 with rows_deleted_at already set: the rows are gone when the call returns. models_retrained_at stays null until the nightly retrain. GET /users/$TEST_USER with the admin key now returns no profile and no events.

Pitfalls#

SymptomCauseFix
consent_violation from an SDK, 422 mode_conflict, or a 400non_profiled with a user_id, or without a session_idSend session_id only in non_profiled mode
consent_requiredmode missingAlways send the mode you resolved from the user's consent
ValidationError on a user idan email or `providerid` passed as user id
dropped_fields not empty in the events answera prop outside the per-type allowlist, or a context key other than locale, app_version, platform, hour_bucket, weekday, region, subscription_tier, tenure_daysRemove the field
422 pii_rejected on a catalog upsertan item attribute named email, name or ipRemove the attribute
422 no_candidatesno published item of the surface's kinds and locale survives the filtersLoad the catalog with that locale; show your own list meanwhile
400 with "unknown field"a field the engine does not know (bodies are decoded strictly)Remove it, or upgrade the engine
Ledger shows more impressions than users sawrecommend called for lists that were never shown, or retriedCall once per render; use "shadow": true for previews
An event in rejected with invalid signaturea secret from before engine 0.2 (no pss_ prefix), or a key other than the secret stringrotate once and sign with the pss_ string as is. Or send without a signature
Interactions not in the tracewrong request_id, or the displayed position sent as position (positions start at 0)Echo the slate's request_id and served position; put the displayed one in displayed_position
401 (unauthorized or wrong_scope)missing, revoked or wrongly scoped keyserve key (pk_s_) for /recommendations and /interactions, ingest key (pk_i_) for /events, /catalog and the user mode, profile and delete calls, admin key (pk_a_) for the rest
429 rate_limitedthe key's rate limit (serve 200/s, ingest 50/s)back off; never retry recommend

Signatures#

Events sent with the ingest key need no signature, and there is no separate SDK key scope. A signature is optional; if you send one it is verified, and a wrong one rejects that event. It is lowercase hex HMAC-SHA256 over the event without signature, serialised as JSON with keys sorted in ASCII order, no whitespace, HTML characters unescaped, U+2028 and U+2029 escaped, numbers as integers, keyed with the pss_ secret string itself. Check your code against the test vectors in Rotate keys and the signing secret. Sign on the backend only.

Where to go next#

esc
  • Type to search every page, heading and code sample.