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

# Tutorial: Cross-Sell

> Build a cross-sell flow that recommends the next-best product without burning trust — uplift-aware ranking, eligibility by purchase history, multi-channel routing.

# Tutorial: Cross-Sell

This tutorial walks through a cross-sell flow that recommends a second product to existing customers. The system needs to know what each customer already owns, what they're likely to buy next, and how much *incremental* revenue each candidate offer brings (not just what they'd buy anyway).

**Business scenario:** a bank's existing-customer base owns one or more of: checking, savings, credit card, mortgage, brokerage. Marketing wants to recommend the next product per customer — and avoid recommending one they already have, one their tier doesn't qualify for, or one they'd convert on without prompting (sleeping dogs).

**What you'll build:**

* A Customer schema that tracks current product holdings
* 4 cross-sell offers with eligibility tied to existing holdings
* A flow whose Score node blends propensity, impact, and an [uplift](/ai-ml/uplift-modeling) term so ranking optimizes *incremental* revenue
* Channel-aware setup so the flow can run across email, in-app, and branch

**Prerequisites:** A running KaireonAI instance, an admin API key, and `curl` + `jq`.

**Time:** 25–30 minutes.

***

## 0. Set up your shell

```bash theme={null}
export BASE="https://your-instance.kaireonai.com"
export API_KEY="sk_live_..."
export TENANT_ID="<your-tenant-id>"
AUTH=(-H "X-API-Key: $API_KEY" -H "X-Tenant-Id: $TENANT_ID" -H "Content-Type: application/json")
```

***

## 1. Define the schema with product holdings

```bash theme={null}
SCHEMA_ID=$(curl -s -X POST "$BASE/api/v1/schemas" "${AUTH[@]}" \
  -d '{
    "name": "customer",
    "displayName": "Customer",
    "fields": [
      { "name": "customer_id",       "dataType": "varchar", "isUnique": true },
      { "name": "tenureMonths",      "dataType": "integer" },
      { "name": "tier",              "dataType": "varchar" },
      { "name": "hasChecking",       "dataType": "boolean" },
      { "name": "hasSavings",        "dataType": "boolean" },
      { "name": "hasCreditCard",     "dataType": "boolean" },
      { "name": "hasMortgage",       "dataType": "boolean" },
      { "name": "hasBrokerage",      "dataType": "boolean" },
      { "name": "avgMonthlyBalance", "dataType": "decimal" }
    ]
  }' | jq -r '.id')
echo "Schema id: $SCHEMA_ID"
```

These boolean holding fields are the gate — qualification rules will use them to exclude offers the customer already owns.

***

## 2. Define cross-sell offers

Offers are created `active` (the Inventory stage only loads active offers). `businessValue` (0–100) feeds ranking's Impact term; `revenueValue` records the dollar figure. Eligibility is attached separately in step 3.

```bash theme={null}
OFFER_SAVINGS=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "High-yield savings", "status": "active", "category": "cross-sell",
  "priority": 70, "businessValue": 40, "revenueValue": 40
}' | jq -r '.id')

OFFER_CARD=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "Rewards credit card", "status": "active", "category": "cross-sell",
  "priority": 80, "businessValue": 80, "revenueValue": 90
}' | jq -r '.id')

OFFER_WEALTH=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "Wealth-management account", "status": "active", "category": "cross-sell",
  "priority": 95, "businessValue": 95, "revenueValue": 250
}' | jq -r '.id')

OFFER_MORTGAGE=$(curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d '{
  "name": "Mortgage pre-approval", "status": "active", "category": "cross-sell",
  "priority": 90, "businessValue": 90, "revenueValue": 500
}' | jq -r '.id')
```

***

## 3. Attach holding-aware qualification rules

Each rule is an `attribute_condition` scoped to one offer at the `eligibility` stage. The `attribute` names the enriched field (Enrich loads schema columns under the `customer.` prefix). Multiple rules on the same offer are AND-combined, so an offer surfaces only when *all* its rules pass. A boolean field compares directly against `true`/`false`; tier membership uses the `in` operator with an array.

```bash theme={null}
# Savings: only for checking-only customers
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Savings — has checking\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_SAVINGS\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasChecking\", \"operator\": \"eq\", \"value\": true } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Savings — no savings yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_SAVINGS\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasSavings\", \"operator\": \"eq\", \"value\": false } }"

# Credit card: not for customers who already have one, and needs 6mo tenure
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Card — no card yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_CARD\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasCreditCard\", \"operator\": \"eq\", \"value\": false } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Card — tenure >= 6mo\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_CARD\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.tenureMonths\", \"operator\": \"gte\", \"value\": 6 } }"

# Wealth: premium/private tier, significant balance, no brokerage yet
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Wealth — no brokerage yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_WEALTH\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasBrokerage\", \"operator\": \"eq\", \"value\": false } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Wealth — premium tier\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_WEALTH\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.tier\", \"operator\": \"in\", \"value\": [\"premium\", \"private\"] } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Wealth — balance >= 50k\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_WEALTH\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.avgMonthlyBalance\", \"operator\": \"gte\", \"value\": 50000 } }"

# Mortgage: no mortgage yet, strong tenure
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Mortgage — none yet\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_MORTGAGE\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.hasMortgage\", \"operator\": \"eq\", \"value\": false } }"
curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
  \"name\": \"Mortgage — tenure >= 24mo\", \"ruleType\": \"attribute_condition\",
  \"scope\": \"offer\", \"scopeId\": \"$OFFER_MORTGAGE\", \"stage\": \"eligibility\",
  \"config\": { \"attribute\": \"customer.tenureMonths\", \"operator\": \"gte\", \"value\": 24 } }"
```

The eligibility rules are the safety net. Even if the propensity model says "this customer would love a credit card", the rule `customer.hasCreditCard == false` keeps the platform honest.

***

## 4. Create the delivery channels

Cross-sell often runs across email, in-app, and (for high-value offers) branch. Each channel is its own entity. `couplingMode: "partial"` means a placement that can't be filled doesn't cascade-empty its siblings in the same channel — each placement is decided independently.

```bash theme={null}
for ch in email in_app branch; do
  curl -s -X POST "$BASE/api/v1/channels" "${AUTH[@]}" \
    -d "{ \"name\": \"$ch\", \"status\": \"active\", \"couplingMode\": \"partial\" }"
done
```

***

## 5. Wire the uplift-aware decision flow

The Score node's `method: "formula"` activates PRIE-U weighting. The four core weights — **P**ropensity, **R**elevance, **I**mpact, **E**mphasis — must sum to 1.0. `upliftWeight` sits *outside* that sum (range 0–2): it applies the uplift dimension, which downweights offers a customer would convert on anyway (sure things) and boosts offers that actually move the needle (persuadable).

The `flowConfig.experiment` block withholds offers from 10% of traffic — the holdout the uplift dimension needs to estimate incremental lift (step 6).

```bash theme={null}
FLOW_ID=$(curl -s -X POST "$BASE/api/v1/decision-flows" "${AUTH[@]}" \
  -d "{
    \"key\": \"cross-sell\",
    \"name\": \"Cross-Sell\",
    \"draftConfig\": {
      \"version\": 2,
      \"flowConfig\": { \"experiment\": { \"enabled\": true, \"holdoutPercent\": 10, \"controlGroupAction\": \"no_offers\" } },
      \"nodes\": [
        { \"id\": \"inv\",   \"type\": \"inventory\",      \"phase\": 1, \"position\": 0,
          \"config\": { \"scope\": \"all\", \"includeStatuses\": [\"active\"] } },
        { \"id\": \"enr\",   \"type\": \"enrich\",         \"phase\": 1, \"position\": 1,
          \"config\": { \"sources\": [ { \"schemaId\": \"$SCHEMA_ID\", \"lookupKey\": \"customer_id\",
            \"fields\": [\"tier\", \"tenureMonths\", \"hasChecking\", \"hasSavings\", \"hasCreditCard\",
              \"hasMortgage\", \"hasBrokerage\", \"avgMonthlyBalance\"] } ] } },
        { \"id\": \"qual\",  \"type\": \"qualify\",        \"phase\": 1, \"position\": 2,
          \"config\": { \"mode\": \"all\" } },
        { \"id\": \"cp\",    \"type\": \"contact_policy\", \"phase\": 1, \"position\": 3,
          \"config\": { \"mode\": \"all\" } },
        { \"id\": \"score\", \"type\": \"score\",          \"phase\": 2, \"position\": 4,
          \"config\": { \"method\": \"formula\", \"formula\": {
            \"propensityWeight\": 0.4, \"relevanceWeight\": 0.15,
            \"impactWeight\": 0.35, \"emphasisWeight\": 0.1, \"upliftWeight\": 0.5 } } },
        { \"id\": \"rank\",  \"type\": \"rank\",           \"phase\": 2, \"position\": 5,
          \"config\": { \"method\": \"topN\", \"maxCandidates\": 5 } },
        { \"id\": \"out\",   \"type\": \"response\",       \"phase\": 3, \"position\": 6,
          \"config\": { \"responseFormat\": \"standard\" } }
      ]
    }
  }" | jq -r '.id')

curl -s -X POST "$BASE/api/v1/decision-flows/publish" "${AUTH[@]}" -d "{ \"id\": \"$FLOW_ID\" }"
```

***

## 6. Feed the uplift signal

`upliftWeight` is live the moment you publish, but the uplift dimension only *changes* rankings once it has a causal signal to read — the per-customer CATE estimate of how much the offer moves conversion versus a control.

That signal comes from the **holdout** you configured in step 5: `flowConfig.experiment` withholds cross-sell offers from 10% of traffic (`controlGroupAction: "no_offers"`), so the platform can compare treated vs. untreated conversion. As holdout and treatment outcomes accumulate through `/respond`, the uplift dimension's CATE estimates sharpen. You need roughly 30 days of decisions with a holdout before the signal is reliable.

See [Uplift Modeling](/ai-ml/uplift-modeling) for the T-learner / X-learner math and how to inspect a model's uplift via `GET /api/v1/algorithm-models/{id}/uplift`.

***

## 7. Get a recommendation

For a 36-month-tenure premium customer with no credit card:

```bash theme={null}
curl -s -X POST "$BASE/api/v1/recommend" "${AUTH[@]}" \
  -d '{ "customerId": "cust_42", "channel": "in_app", "decisionFlowKey": "cross-sell" }'
```

Sample response:

```json theme={null}
{
  "interactionId": "9d21…",
  "recommendationId": "9d21…",
  "customerId": "cust_42",
  "decisionFlowKey": "cross-sell",
  "decisionFlowVersion": 2,
  "channel": "in_app",
  "count": 2,
  "decisions": [
    { "offerId": "…", "offerName": "Rewards credit card", "score": 0.79, "rank": 1, "priority": 80, "personalization": {} },
    { "offerId": "…", "offerName": "High-yield savings",  "score": 0.58, "rank": 2, "priority": 70, "personalization": {} }
  ],
  "meta": { "totalCandidates": 4, "afterQualification": 2, "afterSuppression": 2, "afterContactPolicy": 2, "degradedScoring": false }
}
```

The wealth and mortgage offers were filtered at qualification (`afterQualification` dropped from 4 to 2) because this customer doesn't clear their eligibility rules. Of the two survivors, the uplift dimension pushed the credit card above savings: even with comparable raw propensity, the customer's savings conversion is a near-sure-thing (low incremental lift), so the flow prefers the card, where the offer actually changes the outcome.

To see the uplift/propensity/impact breakdown behind each score, add `?explain=true` — the response then carries an `explanation.rankingScores` object per decision. (The default response shape above does not embed internal ranking factors.)

This is the cross-sell game: **don't pay attribution dollars for conversions you'd get for free.**

***

## 8. How the uplift dimension reasons (conceptual)

Internally, the uplift dimension sorts every (customer × offer) pair into one of four behavioral buckets by its CATE estimate. These buckets are a mental model for *why* the ranking moves — they are not fields in the `/recommend` response:

| Segment          | CATE                     | Behavior                                                          |
| ---------------- | ------------------------ | ----------------------------------------------------------------- |
| **Persuadable**  | strongly positive        | Offer moves the needle; surface it                                |
| **Sure thing**   | near zero                | Will convert anyway; deprioritize                                 |
| **Lost cause**   | negative, low base rate  | Won't convert; suppress                                           |
| **Sleeping dog** | negative, high base rate | Would have converted but the offer *backfires*; suppress strongly |

Sleeping dogs are the dangerous one. A discount sent to a customer who would have converted at full price loses money on both sides — the conversion you'd have gotten *plus* the lost margin.

***

## 9. What's next

* **Layer contact frequency.** Add a `customer_total_cap` contact policy (e.g. `maxTotal: 4`, `periodType: "monthly"`) so cross-sell doesn't crowd out service messages.
* **Per-channel scoring.** The same offer at rank-1 on email may not be rank-1 on in-app. Add `overrides` to the Score node scoped `channel` to route different weights per surface.
* **Measure incrementality.** Once the holdout has run, the [experiments z-test](/api-reference/experiments) reports incremental revenue with a confidence interval — not just raw conversion rate.
* **Multi-step sequencing.** For high-value cross-sells like mortgages, wire a [Journey](/decisioning/journeys) so an email lead is followed (after a qualifying response) by a branch-appointment offer.

***

*See [Churn Prevention](/tutorials/churn-prevention) for the retention scenario, [Industry Templates](/tutorials/industry-templates) for ready-made starter kits, or [Uplift Modeling](/ai-ml/uplift-modeling) for the CATE math.*
