Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt

Use this file to discover all available pages before exploring further.

What this solves

Operators consistently want two related-but-different things:
  1. “Don’t spam this customer.” A global per-day cap on total touches.
  2. “Don’t fatigue this category.” A per-category cap so Premium Cards doesn’t dominate every email.
The contact-policy engine handles both via the frequency_cap ruleType, but the scope is what matters — global, category, subcategory, offer, channel, segment, or placement. Each scope filters a different slice of the alltime interaction summary.

The pattern

A contact policy is {ruleType, scope, scopeId?, config}. For frequency_cap, config carries:
  • maxPerDay / maxPerWeek / maxPerMonth (any subset; missing = no cap on that window)
  • lookbackHours (optional, defaults to the window above)
The engine consults interaction_summaries filtered by (customerId, scope, scopeId, period) and removes any candidate whose count would exceed the cap.

Recipe 1 — Global per-day cap (3 touches/day, all channels, all categories)

curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "Global 3/day",
    "ruleType": "frequency_cap",
    "config": { "maxPerDay": 3 },
    "scopes": [{ "scope": "global", "scopeId": null }],
    "status": "active"
  }'
After three accepted respond events from any customer-offer combination, the next recommend will filter every candidate. Verify: trace.contactPolicyResults[*].reason will read "frequency_cap exceeded: 3/day at scope=global".

Recipe 2 — Per-category cap (Cards category 5/week)

curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "Cards 5/week",
    "ruleType": "frequency_cap",
    "config": { "maxPerWeek": 5 },
    "scopes": [{ "scope": "category", "scopeId": "<cards-category-id>" }],
    "status": "active"
  }'
Now only candidates with offer.categoryId === <cards-category-id> count against the cap. Loans offers are unaffected. The denormalized interaction_summaries.offerCategory column (added in fix #155) makes this query fast.

Recipe 3 — Per-channel cap (Email 2/day, push unlimited)

curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "Email 2/day",
    "ruleType": "frequency_cap",
    "config": { "maxPerDay": 2 },
    "scopes": [{ "scope": "channel", "scopeId": "<email-channel-id>" }],
    "status": "active"
  }'
Combine multiple per-channel caps to express “no more than 2 emails, 1 push, 0 SMS per day.”

Recipe 4 — Per-offer cooldown (don’t show the same offer twice in 24h)

This is a different ruleType — cooldown, not frequency_cap:
curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "24h cooldown",
    "ruleType": "cooldown",
    "config": { "cooldownHours": 24 },
    "scopes": [{ "scope": "offer", "scopeId": "<platinum-card-id>" }],
    "status": "active"
  }'
The engine reads the timestamp of the last impression for that (customer, offer) pair and suppresses if within cooldownHours.

What the trace will show

contactPolicyResults[
  {
    "policyId": "<id>",
    "ruleType": "frequency_cap",
    "scope": "category",
    "scopeId": "<cards-id>",
    "offerId": "<premium-card-id>",
    "decision": "block",
    "reason": "frequency_cap exceeded: customer hit 5/week limit on category <cards-id>"
  }
]

Gotchas

  • Scope mismatches silently pass. If a frequency_cap rule has scope: "category" but scopeId: null, it applies globally — not what you want. Validate scopeId is set when scope is anything other than "global".
  • metric_condition is a different beast. That ruleType reads a Compute node’s output (e.g. impression_count_30d > 10), not raw interaction history. Use it when you want windowed-aggregate-driven caps.
  • maxPerDay resets at UTC midnight unless lookbackHours is set. For rolling 24h, pass lookbackHours: 24.

Proof reference

T4 (frequency_cap 3/day fires) and T128 (#155 offer_category_cap denormalization fix verified) in the proof bundle.