> ## 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: Onboarding & Activation

> Build an onboarding flow that drives new customers to the activation moment — staged offers, gentle escalation, and stopping the program the moment activation fires.

# Tutorial: Onboarding & Activation

The first 30 days of a customer relationship are decisive. A new customer who hits their *activation moment* — the first action that proves the product works for them — converts to long-term retention. A new customer who doesn't, churns silently.

This tutorial builds a flow that nudges new customers through the activation funnel, escalates if they stall, and *stops the program the moment activation fires* — so the next message is the right one for an active customer, not another activation prompt.

**Business scenario:** a SaaS product's activation event is "first project created and shared with a teammate". Roughly 40% of signups never get there. Marketing wants a 30-day sequence: day 1 welcome → day 3 tutorial nudge → day 7 use-case examples → day 14 success story → day 21 personal-help offer → day 28 cancellation-prevention. The moment a customer activates, the sequence terminates.

**What you'll build:**

* A Customer schema with signup, activation, and progress signals
* 6 onboarding offers staged across the 30-day window
* A flow that selects the correct step by tenure-since-signup
* An activation gate that stops the program per customer
* Weekday-business-hours contact policies

**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

The flow gates on tenure-since-signup and recency-of-login. The formula engine can't compute those date differences at decision time, so `daysSinceSignup` and `lastLoginDaysAgo` are real columns your data pipeline refreshes daily. `activatedAt` stays null until the customer activates — that null is the signal the program should keep running.

```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": "daysSinceSignup",     "dataType": "integer" },
      { "name": "activatedAt",         "dataType": "timestamp", "isNullable": true },
      { "name": "lastLoginDaysAgo",    "dataType": "integer" },
      { "name": "projectsCreated",     "dataType": "integer", "defaultValue": "0" },
      { "name": "teammateInvitesSent", "dataType": "integer", "defaultValue": "0" },
      { "name": "tutorialCompleted",   "dataType": "boolean", "defaultValue": "false" },
      { "name": "plan",                "dataType": "varchar" }
    ]
  }' | jq -r '.id')
echo "Schema id: $SCHEMA_ID"
```

`activatedAt` being null is what keeps the program alive; the offer rules below all require it to be absent.

***

## 2. Create the category with a progress score

A computed custom field surfaces a per-customer *output* value in the response's `personalization` object — useful for an in-app progress bar. It runs in the Compute stage (after gating), so it's a display value, not a gate. The formula uses only supported syntax (arithmetic, comparison, ternary); `outputType` is `number` or `text`.

```bash theme={null}
CATEGORY_ID=$(curl -s -X POST "$BASE/api/v1/categories" "${AUTH[@]}" \
  -d '{
    "name": "Onboarding",
    "customFields": [
      {
        "name": "completionPct",
        "type": "computed",
        "formula": "(customer.projectsCreated > 0 ? 50 : 0) + (customer.teammateInvitesSent > 0 ? 50 : 0)",
        "outputType": "number"
      }
    ]
  }' | jq -r '.id')
echo "Category id: $CATEGORY_ID"
```

`completionPct` is a quick health score: 0 = "haven't started", 100 = "completed both activation precursors".

***

## 3. Define the 6 staged offers

Each offer belongs to the `Onboarding` category and activates only during its day-window slot. Offers are `active`; eligibility is attached in step 4.

```bash theme={null}
mk_offer () {   # $1 name  $2 priority  $3 businessValue
  curl -s -X POST "$BASE/api/v1/offers" "${AUTH[@]}" -d "{
    \"name\": \"$1\", \"status\": \"active\",
    \"category\": \"onboarding\", \"categoryId\": \"$CATEGORY_ID\",
    \"priority\": $2, \"businessValue\": $3 }" | jq -r '.id'
}

OFFER_D1=$(mk_offer  "Day 1 — Welcome"                 60 40)
OFFER_D3=$(mk_offer  "Day 3 — Try the tutorial"        65 45)
OFFER_D7=$(mk_offer  "Day 7 — See real-world use cases" 70 50)
OFFER_D14=$(mk_offer "Day 14 — How similar teams do it" 75 55)
OFFER_D21=$(mk_offer "Day 21 — Book a setup call"       85 80)
OFFER_D28=$(mk_offer "Day 28 — Stay another month"      95 50)
```

The Day-28 offer is a loss-leader — its up-front cost outweighs immediate margin, justified by the retained LTV the platform won't see for months. Track that cost in the offer's `budget` block; keep `margin` at its default (0 or higher — the API rejects negative margins).

***

## 4. Attach the staged eligibility rules

Every offer requires `customer.activatedAt` to be absent (the `not_exists` operator passes only when the field is null/unset) plus its day-window and step-specific conditions. Rules on the same offer are AND-combined.

```bash theme={null}
add_rule () {   # $1 offerId  $2 name  $3 attribute  $4 operator  $5 valueJson
  curl -s -X POST "$BASE/api/v1/qualification-rules" "${AUTH[@]}" -d "{
    \"name\": \"$2\", \"ruleType\": \"attribute_condition\",
    \"scope\": \"offer\", \"scopeId\": \"$1\", \"stage\": \"eligibility\",
    \"config\": { \"attribute\": \"$3\", \"operator\": \"$4\", \"value\": $5 } }"
}

# The activation stop-gate — every onboarding offer carries it.
for O in "$OFFER_D1" "$OFFER_D3" "$OFFER_D7" "$OFFER_D14" "$OFFER_D21" "$OFFER_D28"; do
  add_rule "$O" "Not activated yet" "customer.activatedAt" "not_exists" "null"
done

# Day 1 — window 0-2
add_rule "$OFFER_D1"  "D1 window start" "customer.daysSinceSignup" "gte" "0"
add_rule "$OFFER_D1"  "D1 window end"   "customer.daysSinceSignup" "lte" "2"

# Day 3 — window 3-5, tutorial not done
add_rule "$OFFER_D3"  "D3 window start" "customer.daysSinceSignup" "gte" "3"
add_rule "$OFFER_D3"  "D3 window end"   "customer.daysSinceSignup" "lte" "5"
add_rule "$OFFER_D3"  "D3 tutorial open" "customer.tutorialCompleted" "eq" "false"

# Day 7 — window 7-9, no project yet
add_rule "$OFFER_D7"  "D7 window start" "customer.daysSinceSignup" "gte" "7"
add_rule "$OFFER_D7"  "D7 window end"   "customer.daysSinceSignup" "lte" "9"
add_rule "$OFFER_D7"  "D7 no project"   "customer.projectsCreated" "eq" "0"

# Day 14 — window 14-16
add_rule "$OFFER_D14" "D14 window start" "customer.daysSinceSignup" "gte" "14"
add_rule "$OFFER_D14" "D14 window end"   "customer.daysSinceSignup" "lte" "16"

# Day 21 — window 21-23, only for recently-active users (logged in within 7 days)
add_rule "$OFFER_D21" "D21 window start" "customer.daysSinceSignup" "gte" "21"
add_rule "$OFFER_D21" "D21 window end"   "customer.daysSinceSignup" "lte" "23"
add_rule "$OFFER_D21" "D21 active user"  "customer.lastLoginDaysAgo" "lte" "7"

# Day 28 — window 28-30, stalled (still no project)
add_rule "$OFFER_D28" "D28 window start" "customer.daysSinceSignup" "gte" "28"
add_rule "$OFFER_D28" "D28 window end"   "customer.daysSinceSignup" "lte" "30"
add_rule "$OFFER_D28" "D28 stalled"      "customer.projectsCreated" "eq" "0"
```

Encoding the stop condition as a rule on every offer is the pattern: activation flips one column; the `not_exists` gate sees it; every onboarding offer stops firing at once (covered in step 7).

***

## 5. Contact policies — weekdays, business hours, once a day

Onboarding messages over the weekend feel desperate. A `time_window` policy restricts delivery to Mon–Fri business hours (day names are three-letter, capitalized; hours are 0–23), and a `customer_total_cap` limits to one onboarding contact per day.

```bash theme={null}
curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "Onboarding — weekdays 09:00-18:00",
    "ruleType": "time_window",
    "scope": "global",
    "config": {
      "daysOfWeek": ["Mon","Tue","Wed","Thu","Fri"],
      "startHour": 9, "endHour": 18,
      "timezone": "America/New_York"
    }
  }'

curl -s -X POST "$BASE/api/v1/contact-policies" "${AUTH[@]}" \
  -d '{
    "name": "Onboarding — max 1 per day",
    "ruleType": "customer_total_cap",
    "scope": "global",
    "config": { "maxTotal": 1, "periodType": "daily" }
  }'
```

Together: at most one onboarding contact per customer per day, only Mon–Fri 09:00–18:00. A Saturday signup gets its day-1 welcome the following Monday morning.

***

## 6. Wire the onboarding flow

Onboarding is *deterministic* by design — the right offer for day-7 is always the day-7 offer, so the Score node uses `method: "priority_weighted"` (it honors the `priority` field directly rather than an ML propensity). The Compute node attaches `completionPct`.

```bash theme={null}
FLOW_ID=$(curl -s -X POST "$BASE/api/v1/decision-flows" "${AUTH[@]}" \
  -d "{
    \"key\": \"onboarding-activation\",
    \"name\": \"Onboarding & Activation\",
    \"draftConfig\": {
      \"version\": 2,
      \"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\": [\"daysSinceSignup\", \"activatedAt\", \"lastLoginDaysAgo\",
              \"projectsCreated\", \"teammateInvitesSent\", \"tutorialCompleted\"] } ] } },
        { \"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\": \"priority_weighted\" } },
        { \"id\": \"rank\",    \"type\": \"rank\",           \"phase\": 2, \"position\": 5,
          \"config\": { \"method\": \"topN\", \"maxCandidates\": 1 } },
        { \"id\": \"compute\", \"type\": \"compute\",        \"phase\": 3, \"position\": 6,
          \"config\": { \"overrides\": [], \"extras\": [], \"transforms\": [] } },
        { \"id\": \"out\",     \"type\": \"response\",       \"phase\": 3, \"position\": 7,
          \"config\": { \"responseFormat\": \"standard\" } }
      ]
    }
  }" | jq -r '.id')

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

A day-7 customer who hasn't created a project gets exactly one decision back:

```json theme={null}
{
  "customerId": "cust_new_1",
  "decisionFlowKey": "onboarding-activation",
  "count": 1,
  "decisions": [
    { "offerId": "…", "offerName": "Day 7 — See real-world use cases", "score": 70, "rank": 1,
      "priority": 70, "categoryName": "Onboarding", "personalization": { "completionPct": 0 } }
  ],
  "meta": { "totalCandidates": 6, "afterQualification": 1, "afterContactPolicy": 1, "degradedScoring": false }
}
```

Five of the six offers failed qualification (wrong day-window); only the day-7 offer survived. With `method: "priority_weighted"`, `score` mirrors the offer's `priority`.

***

## 7. The activation stop

Activation is a fact about the customer record, so the stop is driven by data, not by a special API call. Two things happen when a customer activates:

1. **Your product writes `activatedAt` into the customer record** — through the same data path that populates the `ds_customer` table (a pipeline, a bulk upsert, or your app's own write). On the next `/recommend`, the Enrich stage loads a non-null `customer.activatedAt`, every onboarding offer's `not_exists` rule fails, and the flow returns `count: 0` — the program has stopped *for this customer*.

2. **Optionally, record the activation as an outcome** for measurement. Register an outcome type once, then record it against the recommendation the customer acted on. (`/respond` records interactions and updates models; it does not itself mutate schema columns.)

```bash theme={null}
# Register the activation outcome type (one-time)
curl -s -X POST "$BASE/api/v1/outcome-types" "${AUTH[@]}" \
  -d '{ "key": "activated", "name": "Activated", "classification": "positive", "category": "response" }'

# Record it against a prior recommendation (idempotencyKey is required)
curl -s -X POST "$BASE/api/v1/respond" "${AUTH[@]}" \
  -d '{
    "customerId": "cust_new_1",
    "recommendationId": "<recommendationId from /recommend>",
    "rank": 1,
    "outcome": "activated",
    "idempotencyKey": "cust_new_1-activated-2026-01-15"
  }'
```

Encoding the stop as a qualification rule — rather than as branching logic inside the flow — keeps the flow simple: activation flips one column, the rules see it, and the offers stop firing.

***

## 8. What success looks like

Track three numbers on the Operations dashboard:

| Metric                                     | Source                                                  | Target     |
| ------------------------------------------ | ------------------------------------------------------- | ---------- |
| **Activation rate (30d)**                  | customers with non-null `activatedAt` ÷ signups         | > 60%      |
| **Avg days to activate**                   | mean of `activatedAt − signupDate`                      | \< 14 days |
| **Onboarding cost per activated customer** | onboarding impressions × cost-per-message ÷ activations | \< \$0.50  |

If activation rate drops below target, the most common cause is the schema not capturing the right signal. Add a column, update the pipeline that maintains it, re-deploy — the flow picks up the new signal at the next decision.

***

## 9. What's next

* **Activation-funnel A/B.** Add a `flowConfig.experiment` holdout that gets *no* onboarding messages. If activation rate is similar, the program is decorating outcomes that would have happened anyway.
* **Plan-specific tracks.** Enterprise customers need longer, more white-glove onboarding than free-tier. Add a `customer.plan` rule to fork the flow per tier.
* **Inactivity side-flows.** If `customer.lastLoginDaysAgo` climbs while still in onboarding, route to a re-engagement sub-flow via a `conditional` node — see [Journeys](/decisioning/journeys).
* **In-product nudges.** Some onboarding steps are in-app banners, not emails. Request them with `channel: "in_app"` to surface the right step contextually during a session.

***

*See [Churn Prevention](/tutorials/churn-prevention), [Cross-Sell](/tutorials/cross-sell), and [Winback](/tutorials/winback) for the other three core lifecycle flows. Together they cover the four phases of the customer relationship: onboard, grow, retain, recover.*
