# Integrate with your AI agent

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

Source: https://staging.patterna.nl/docs/agents/integrate-with-your-ai-agent

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://api.patterna.nl/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:

```sh
export PATTERNA_API="https://api.patterna.nl/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:

```sh
export TEST_USER="u_$(openssl rand -hex 16)"
echo "$TEST_USER"
```

## Step 1. 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:

```sh
export PATTERNA_SERVE_KEY="paste-the-serve-secret"
export PATTERNA_INGEST_KEY="paste-the-ingest-secret"
```

## Step 2. 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.

## Step 3. 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`.

## Step 4. 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.

## Step 5. 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.

## Step 6. 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.

## Step 7. 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`.

## Step 8. 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`.

## Step 9. 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

| 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 | Mint a random id and keep the mapping on your side |
| `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](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret#sign-an-event). Sign on the backend only.

## Where to go next

- Per-endpoint examples in curl, TypeScript, Go and Python: [Serve scope](https://staging.patterna.nl/docs/api-serve), [Ingest scope](https://staging.patterna.nl/docs/api-ingest), [Admin scope](https://staging.patterna.nl/docs/api-admin).
- SDKs with timeouts, fallback and the offline queue: [SDK TypeScript](https://staging.patterna.nl/docs/connector-sdk-typescript), [SDK Go](https://staging.patterna.nl/docs/connector-sdk-go).
- The machine-readable index: [llms.txt](https://staging.patterna.nl/docs/agents/llms).
