Integrate with your AI agent
A self-contained brief a coding agent can execute end to end, with a check after every call.
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#
- 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|.... non_profiledrequests carry nouser_id. They carry asession_idonly. Sending both is a privacy bug, not a style issue.- Impressions are logged server-side. Every
POST /recommendationswrites 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": truefor that). - Keys stay on the backend. No Patterna key and no signing secret in an app bundle or in client-side code.
- Report the served position and the kind. Use the
positionfrom the slate (positions start at 0:items[0]is position0), even if the app re-sorts on the device, and the item'skindfrom the slate. - Quote the request id. Every response carries
X-Request-Id. Log it with every failure.
Prerequisites#
- An
adminkey for the tenant, created once by a person in the panel (Connectors or Governance). The secret is shown once. It starts withpk_a_; serve keys start withpk_s_, ingest keys withpk_i_. - A shell with
curlandjq. - These variables:
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:
export TEST_USER="u_$(openssl rand -hex 16)"
echo "$TEST_USER"Create a serve key and an ingest key#
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 -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:
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 -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 -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 -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 -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 -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 -sS -X PUT "$PATTERNA_API/users/$TEST_USER/mode" \
-H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
-H "Content-Type: application/json" \
-d '{"mode": "personalized"}'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 -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.jsonNon-profiled (no user_id, a session_id instead):
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:
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 -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 -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 -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#
| Symptom | Cause | Fix |
|---|---|---|
consent_violation from an SDK, 422 mode_conflict, or a 400 | non_profiled with a user_id, or without a session_id | Send session_id only in non_profiled mode |
consent_required | mode missing | Always send the mode you resolved from the user's consent |
ValidationError on a user id | an email or `provider | id` passed as user id |
dropped_fields not empty in the events answer | a prop outside the per-type allowlist, or a context key other than locale, app_version, platform, hour_bucket, weekday, region, subscription_tier, tenure_days | Remove the field |
422 pii_rejected on a catalog upsert | an item attribute named email, name or ip | Remove the attribute |
422 no_candidates | no published item of the surface's kinds and locale survives the filters | Load 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 saw | recommend called for lists that were never shown, or retried | Call once per render; use "shadow": true for previews |
An event in rejected with invalid signature | a secret from before engine 0.2 (no pss_ prefix), or a key other than the secret string | rotate once and sign with the pss_ string as is. Or send without a signature |
| Interactions not in the trace | wrong 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 key | serve 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_limited | the 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#
- Per-endpoint examples in curl, TypeScript, Go and Python: Serve scope, Ingest scope, Admin scope.
- SDKs with timeouts, fallback and the offline queue: SDK TypeScript, SDK Go.
- The machine-readable index: llms.txt.