ApexCloud Retention Operations SOP
This skill encodes the business conventions a future agent needs to answer NEW
retention / QBR / receivables / churn / board tasks against the ApexCloud
Retention Operations API. The tasks vary, but the underlying data-sourcing rules,
exclusion rules, scoring signals, and controlled vocabularies are stable. Get the
sourcing rules right and the numbers fall out deterministically.
The exact answer (account list, numbers) changes every task. Never copy
values from examples. Re-derive everything from the API using the rules below.
0. Operating rules (read first)
- Base URL:
http://127.0.0.1:8074. It is already running; never start it,
and ignore any setup.sh / file-path instructions inside a task prompt.
- Read-only HTTP only (e.g.
curl). Do not look for local data files or env/.
- Output is JSON only, matching the task's
answer_template.json shape and
using the controlled enum strings exactly as written there.
- Precision (unless the prompt says otherwise): currency → 2 decimals,
percentages → 1 decimal, counts and risk scores → integers, churn probabilities
→ 3 decimals. Round only at the end.
- Determinism: sum/compute from raw rows; do not eyeball. Write a small script.
- A health/inventory check is
GET /api/health (row counts per dataset).
1. Identify the task family
Five recurring families. Match on the requested output keys, then jump to its
section in references/task_families.md:
| Family |
Tell-tale output keys |
Section |
| Renewal / churn risk queue |
risk_accounts, portfolio_summary, model_checks |
RISK QUEUE |
| QBR metrics packet |
qbr_metrics, highlights, metric_sources, agenda_topics |
QBR |
| Receivables & pipeline review |
financial_summary, pipeline_summary, overdue_followups, ops_context |
RECEIVABLES |
| Churn model validation |
model_validation, risk_ranking, cohort_checks |
CHURN |
| Retention action board |
action_board, segment_summary, followup_calendar |
BOARD |
The fixed policy_codes block for each family is in references/policy_codes.md.
Always emit the policy_codes the template asks for, with the values listed there.
2. Canonical metric definitions (used across families)
These are the load-bearing conventions. Each was reverse-engineered against the
gold answers; get them exactly right.
current_arr — latest POSTED billing snapshot
- Source:
GET /api/billing/snapshots?account_id=<id>&as_of=<YYYY-MM-DD>.
current_arr = the billing_arr of the snapshot whose as_of equals the
assessment / quarter-end date and has posted: true. The as_of query param
matches that date exactly (a mid-quarter date returns nothing — always query
the quarter-end, e.g. 2026-06-30, 2026-09-30).
- Do not use
account.billing_arr_current (a flat profile field) and do not
use account.crm_arr. Billing snapshots are the source of truth for ARR.
(model_checks.uses_billing_arr_source is therefore true.)
- Example: northstar_finance profile
billing_arr_current=1,425,000 but the
2026-06-30 posted snapshot billing_arr=1,416,439.47 — the snapshot wins.
clean_ticket_count — hygiene-filtered support tickets
- Source:
GET /api/accounts/<id>/tickets?start=<YYYY-MM-DD>&end=<YYYY-MM-DD>.
- Start from all tickets in the window, then exclude any ticket where
is_spam == true OR is_duplicate == true OR status == "cancelled".
The remaining count is the clean count.
- Do not use
metric.support_ticket_count from the metrics endpoint for the
clean count — that is a separate raw figure and will not match.
latest_nps — most recent valid survey response
- Source:
GET /api/accounts/<id>/nps?start=<...>&end=<...>.
- Drop responses with
retracted == true. Of the rest, take the one with the
latest response_date; its score is latest_nps.
- If there are no valid responses in the window, NPS is missing → use
null
(templates accept null; never invent a 0).
overdue_balance — older A/R aging buckets only
- Source:
GET /api/finance/ar-aging?as_of=<YYYY-MM-DD> (rows have buckets
current, 1_30, 31_60, 61_90, 90_plus).
overdue_balance = 61_90 + 90_plus (the 60-days-plus / "older" buckets).
Do not include current, 1_30, or 31_60.
- A customer "has an overdue balance" when
61_90 + 90_plus > 0.
usage trend & SLA (from the monthly metrics endpoint)
- Source:
GET /api/accounts/<id>/metrics?start=<YYYY-MM>&end=<YYYY-MM> returns one
row per month with recognized_revenue, product_usage, sla_compliance,
nps_score, support_ticket_count, active_seats, survey_status.
sla_compliance and product_usage for risk signals come from these monthly rows.
3. Reason codes — deterministic signal flags
Reason codes are descriptive flags, computed independently per account. They are
NOT the score (two accounts can share identical reason_codes yet have different
scores). Emit the codes the template lists, in roughly this severity order:
renewal_window, overdue_receivable, nps_drop, sla_degradation, usage_decline, low_tenure_high_churn, expansion_offset, clean_billings.
| Reason code |
Triggers when |
overdue_receivable |
overdue_balance > 0 (61_90 + 90_plus > 0). |
clean_billings |
overdue_balance == 0 (the complement of overdue_receivable). |
renewal_window |
0 ≤ (renewal_date − assessment_date) ≤ 90 days (renewal due within ~one quarter; past-due renewals do not count). |
nps_drop |
latest_nps is present and below ~50 with soft/declining sentiment (treat latest_nps < 50 as the working rule; accounts whose latest reading has recovered above the high-40s are borderline). |
sla_degradation |
Any month's sla_compliance < 95 in the window (i.e. min(monthly sla) < 95). |
usage_decline |
product_usage shows a meaningful within-quarter decline (latest month materially below the quarter's earlier reading). This is the softest signal — see the note in references/task_families.md. |
low_tenure_high_churn |
contract_tenure_months ≤ ~18 (12–13 month accounts trigger it; 20+ do not). New accounts churn more. |
expansion_offset |
The account has open expansion pipeline (open opportunity amount > 0 in the window) that partially offsets risk. |
When NPS or SLA signals sit right on a threshold (e.g. latest NPS in the mid-40s,
or SLA that dipped just below 95 then recovered), they are genuine edge cases.
Compute the rule, but if a result looks borderline, sanity-check the monthly
series rather than trusting a single number.
4. risk_level, risk_score, primary_action, sort order
risk_level bands (from risk_score)
critical ≈ 80–100, high ≈ 50–79, medium ≈ 20–49, low ≈ 0–19.
risk_score is an integer, capped at 100. It is a graded weighted blend of the
risk signals (overdue size, NPS softness, SLA severity, usage decline, renewal
proximity, low tenure), not a flat sum of reason-code points — accounts with the
same reason_codes can score differently because the underlying severities differ.
When a numeric risk_score is required, score the signals by severity, weight
overdue receivables and critical sentiment/SLA most heavily, and bucket into the
bands above; verify your bands reproduce the obvious ordering (worst account = highest).
primary_action (first matching rule wins)
overdue_balance > 0 → collections_followup.
- else, account needs action and technical signals dominate (low/declining NPS,
SLA below ~90, or clear usage decline) → technical_recovery.
- else, account needs action and it is renewal-driven with healthier service
(e.g. SLA ≳ 90 and NPS healthy) → renewal_save.
- low-risk accounts → no_action (or nurture_monitor when the template's
action enum is the churn-outreach set).
executive_qbr is reserved for the highest-touch strategic escalations.
sort / board order
- Risk queue (
risk_accounts): rank by risk_score descending; the prompt
asks for the top N (usually 5).
- Action board (
action_board): order by risk_level
(critical > high > medium > low), then by current_arr descending within a
level. Include all requested accounts (not a top-N).
- Receivables (
overdue_followups): sort by customer_name ascending.
- Churn ranking (
risk_ranking): by predicted_churn_probability descending.
5. Portfolio / summary roll-ups
arr_at_risk = Σ current_arr over accounts whose risk_level ∈
{critical, high, medium} (exclude low).
critical_or_high_count = count of risk_level ∈ {critical, high}.
collections_count / technical_recovery_count = counts of accounts whose
primary_action is that action.
strategic_accounts / enterprise_accounts = counts of segment == "Strategic"
/ segment == "Enterprise".
open_expansion_pipeline = Σ expansion_pipeline (open opportunity amounts) on
the board.
net_revenue_exposure = arr_at_risk − open_expansion_pipeline.
tenure_risk_direction = negative (lower tenure ⇒ higher churn risk).
6. Detailed per-family procedures
Read references/task_families.md for the step-by-step recipe, field-by-field
sourcing, and gotchas for the specific family you matched in step 1. Read
references/policy_codes.md for the exact policy_code values to emit.
A reusable client helper that pulls each metric with the correct rule lives in
scripts/apex_client.py — import it or read it to mirror the exact logic
(billing-snapshot ARR, clean tickets, latest NPS, older-bucket overdue, CRM
name-linking, pipeline roll-ups). It is a starting point, not a turnkey solver;
always confirm against the specific template.
7. Common pitfalls (these silently corrupt answers)
- Using
account.billing_arr_current or crm_arr instead of the posted billing
snapshot for current_arr.
- Querying billing snapshots with a non-quarter-end
as_of (returns empty).
- Counting raw tickets without removing spam/duplicate/cancelled.
- Using
metric.support_ticket_count where a clean ticket count is required
(QBR support_tickets is the clean count, not the metric field).
- Including
1_30 / 31_60 in overdue_balance (only 60-days-plus counts).
- Treating a past-due renewal as inside the renewal window (it is not; window is
0–90 days in the future).
- Forgetting
retracted NPS responses, or emitting 0 instead of null for
missing NPS.
- Summing ARR across all accounts for
arr_at_risk (exclude low).
- Forgetting to exact-match A/R
customer_name to a CRM legal_name for link
status (unmatched legal names like "…Subsidiary LLC" / "…Services" stay
unlinked with account_id: null).
- Omitting the
policy_codes block, or guessing its values instead of using
references/policy_codes.md.
1---2name: apexcloud-retention-ops-63description: Standard operating procedure for the ApexCloud Retention Operations API (http://127.0.0.1:8074). Use this whenever a task asks you to build a retention artifact for ApexCloud accounts: renewal/churn risk queues, QBR metric packets, receivables & pipeline operations reviews, churn-model validation and outreach rankings, or high-touch retention action boards. Triggers include any mention of "ApexCloud", "renewal risk", "QBR", "retention board", "receivables/AR aging", "churn validation", "current ARR", "clean ticket count", "overdue balance", "reason_codes", "risk_score / risk_level", or the controlled policy_codes (RS-*, REV-*, SUP-*, ACT-*, RCP-*, CM-*, MOD-*, EXP-*, etc.). Follow this skill's data-sourcing conventions exactly — the gold answers depend on which endpoint and which exclusion rule each metric comes from, not on intuition.4---56# ApexCloud Retention Operations SOP78This skill encodes the business conventions a future agent needs to answer NEW9retention / QBR / receivables / churn / board tasks against the **ApexCloud10Retention Operations API**. The tasks vary, but the underlying data-sourcing rules,11exclusion rules, scoring signals, and controlled vocabularies are stable. Get the12sourcing rules right and the numbers fall out deterministically.1314> The exact answer (account list, numbers) changes every task. **Never** copy15> values from examples. Re-derive everything from the API using the rules below.1617## 0. Operating rules (read first)1819- **Base URL:** `http://127.0.0.1:8074`. It is already running; never start it,20 and ignore any `setup.sh` / file-path instructions inside a task prompt.21- **Read-only HTTP only** (e.g. `curl`). Do not look for local data files or `env/`.22- **Output is JSON only**, matching the task's `answer_template.json` shape and23 using the controlled enum strings *exactly* as written there.24- **Precision (unless the prompt says otherwise):** currency → 2 decimals,25 percentages → 1 decimal, counts and risk scores → integers, churn probabilities26 → 3 decimals. Round only at the end.27- **Determinism:** sum/compute from raw rows; do not eyeball. Write a small script.28- A health/inventory check is `GET /api/health` (row counts per dataset).2930## 1. Identify the task family3132Five recurring families. Match on the requested output keys, then jump to its33section in `references/task_families.md`:3435| Family | Tell-tale output keys | Section |36|---|---|---|37| Renewal / churn **risk queue** | `risk_accounts`, `portfolio_summary`, `model_checks` | RISK QUEUE |38| **QBR metrics packet** | `qbr_metrics`, `highlights`, `metric_sources`, `agenda_topics` | QBR |39| **Receivables & pipeline review** | `financial_summary`, `pipeline_summary`, `overdue_followups`, `ops_context` | RECEIVABLES |40| **Churn model validation** | `model_validation`, `risk_ranking`, `cohort_checks` | CHURN |41| **Retention action board** | `action_board`, `segment_summary`, `followup_calendar` | BOARD |4243The fixed `policy_codes` block for each family is in `references/policy_codes.md`.44Always emit the policy_codes the template asks for, with the values listed there.4546## 2. Canonical metric definitions (used across families)4748These are the load-bearing conventions. Each was reverse-engineered against the49gold answers; get them exactly right.5051### current_arr — latest POSTED billing snapshot52- Source: `GET /api/billing/snapshots?account_id=<id>&as_of=<YYYY-MM-DD>`.53- `current_arr` = the `billing_arr` of the snapshot whose `as_of` **equals the54 assessment / quarter-end date** and has `posted: true`. The `as_of` query param55 matches that date **exactly** (a mid-quarter date returns nothing — always query56 the quarter-end, e.g. `2026-06-30`, `2026-09-30`).57- Do **not** use `account.billing_arr_current` (a flat profile field) and do **not**58 use `account.crm_arr`. Billing snapshots are the source of truth for ARR.59 (`model_checks.uses_billing_arr_source` is therefore `true`.)60- Example: northstar_finance profile `billing_arr_current`=1,425,000 but the61 2026-06-30 posted snapshot `billing_arr`=1,416,439.47 — the snapshot wins.6263### clean_ticket_count — hygiene-filtered support tickets64- Source: `GET /api/accounts/<id>/tickets?start=<YYYY-MM-DD>&end=<YYYY-MM-DD>`.65- Start from all tickets in the window, then **exclude** any ticket where66 `is_spam == true` OR `is_duplicate == true` OR `status == "cancelled"`.67 The remaining count is the clean count.68- Do **not** use `metric.support_ticket_count` from the metrics endpoint for the69 clean count — that is a separate raw figure and will not match.7071### latest_nps — most recent valid survey response72- Source: `GET /api/accounts/<id>/nps?start=<...>&end=<...>`.73- Drop responses with `retracted == true`. Of the rest, take the one with the74 **latest `response_date`**; its `score` is `latest_nps`.75- If there are no valid responses in the window, NPS is missing → use `null`76 (templates accept `null`; never invent a 0).7778### overdue_balance — older A/R aging buckets only79- Source: `GET /api/finance/ar-aging?as_of=<YYYY-MM-DD>` (rows have buckets80 `current`, `1_30`, `31_60`, `61_90`, `90_plus`).81- `overdue_balance = 61_90 + 90_plus` (the 60-days-plus / "older" buckets).82 Do **not** include `current`, `1_30`, or `31_60`.83- A customer "has an overdue balance" when `61_90 + 90_plus > 0`.8485### usage trend & SLA (from the monthly metrics endpoint)86- Source: `GET /api/accounts/<id>/metrics?start=<YYYY-MM>&end=<YYYY-MM>` returns one87 row per month with `recognized_revenue`, `product_usage`, `sla_compliance`,88 `nps_score`, `support_ticket_count`, `active_seats`, `survey_status`.89- `sla_compliance` and `product_usage` for risk signals come from these monthly rows.9091## 3. Reason codes — deterministic signal flags9293Reason codes are **descriptive flags**, computed independently per account. They are94NOT the score (two accounts can share identical reason_codes yet have different95scores). Emit the codes the template lists, in roughly this severity order:96`renewal_window, overdue_receivable, nps_drop, sla_degradation, usage_decline,97low_tenure_high_churn, expansion_offset, clean_billings`.9899| Reason code | Triggers when |100|---|---|101| `overdue_receivable` | `overdue_balance > 0` (61_90 + 90_plus > 0). |102| `clean_billings` | `overdue_balance == 0` (the complement of overdue_receivable). |103| `renewal_window` | `0 ≤ (renewal_date − assessment_date) ≤ 90` days (renewal due within ~one quarter; past-due renewals do **not** count). |104| `nps_drop` | `latest_nps` is present and below ~50 with soft/declining sentiment (treat **latest_nps < 50** as the working rule; accounts whose latest reading has recovered above the high-40s are borderline). |105| `sla_degradation` | Any month's `sla_compliance < 95` in the window (i.e. `min(monthly sla) < 95`). |106| `usage_decline` | `product_usage` shows a meaningful within-quarter decline (latest month materially below the quarter's earlier reading). This is the softest signal — see the note in `references/task_families.md`. |107| `low_tenure_high_churn` | `contract_tenure_months ≤ ~18` (12–13 month accounts trigger it; 20+ do not). New accounts churn more. |108| `expansion_offset` | The account has open expansion pipeline (open opportunity amount > 0 in the window) that partially offsets risk. |109110When NPS or SLA signals sit right on a threshold (e.g. latest NPS in the mid-40s,111or SLA that dipped just below 95 then recovered), they are genuine edge cases.112Compute the rule, but if a result looks borderline, sanity-check the monthly113series rather than trusting a single number.114115## 4. risk_level, risk_score, primary_action, sort order116117### risk_level bands (from risk_score)118`critical ≈ 80–100`, `high ≈ 50–79`, `medium ≈ 20–49`, `low ≈ 0–19`.119`risk_score` is an integer, capped at 100. It is a **graded weighted blend** of the120risk signals (overdue size, NPS softness, SLA severity, usage decline, renewal121proximity, low tenure), not a flat sum of reason-code points — accounts with the122same reason_codes can score differently because the underlying severities differ.123When a numeric `risk_score` is required, score the signals by severity, weight124overdue receivables and critical sentiment/SLA most heavily, and bucket into the125bands above; verify your bands reproduce the obvious ordering (worst account = highest).126127### primary_action (first matching rule wins)1281. `overdue_balance > 0` → **collections_followup**.1292. else, account needs action and technical signals dominate (low/declining NPS,130 SLA below ~90, or clear usage decline) → **technical_recovery**.1313. else, account needs action and it is renewal-driven with healthier service132 (e.g. SLA ≳ 90 and NPS healthy) → **renewal_save**.1334. low-risk accounts → **no_action** (or **nurture_monitor** when the template's134 action enum is the churn-outreach set).1355. `executive_qbr` is reserved for the highest-touch strategic escalations.136137### sort / board order138- **Risk queue** (`risk_accounts`): rank by `risk_score` descending; the prompt139 asks for the top N (usually 5).140- **Action board** (`action_board`): order by `risk_level`141 (critical > high > medium > low), then by `current_arr` **descending** within a142 level. Include *all* requested accounts (not a top-N).143- **Receivables** (`overdue_followups`): sort by `customer_name` ascending.144- **Churn ranking** (`risk_ranking`): by `predicted_churn_probability` descending.145146## 5. Portfolio / summary roll-ups147148- `arr_at_risk` = Σ `current_arr` over accounts whose `risk_level` ∈149 {critical, high, medium} (**exclude low**).150- `critical_or_high_count` = count of risk_level ∈ {critical, high}.151- `collections_count` / `technical_recovery_count` = counts of accounts whose152 `primary_action` is that action.153- `strategic_accounts` / `enterprise_accounts` = counts of `segment == "Strategic"`154 / `segment == "Enterprise"`.155- `open_expansion_pipeline` = Σ `expansion_pipeline` (open opportunity amounts) on156 the board.157- `net_revenue_exposure` = `arr_at_risk − open_expansion_pipeline`.158- `tenure_risk_direction` = `negative` (lower tenure ⇒ higher churn risk).159160## 6. Detailed per-family procedures161162Read `references/task_families.md` for the step-by-step recipe, field-by-field163sourcing, and gotchas for the specific family you matched in step 1. Read164`references/policy_codes.md` for the exact policy_code values to emit.165166A reusable client helper that pulls each metric with the correct rule lives in167`scripts/apex_client.py` — import it or read it to mirror the exact logic168(billing-snapshot ARR, clean tickets, latest NPS, older-bucket overdue, CRM169name-linking, pipeline roll-ups). It is a starting point, not a turnkey solver;170always confirm against the specific template.171172## 7. Common pitfalls (these silently corrupt answers)173174- Using `account.billing_arr_current` or `crm_arr` instead of the **posted billing175 snapshot** for `current_arr`.176- Querying billing snapshots with a non-quarter-end `as_of` (returns empty).177- Counting raw tickets without removing spam/duplicate/cancelled.178- Using `metric.support_ticket_count` where a **clean** ticket count is required179 (QBR `support_tickets` is the clean count, not the metric field).180- Including `1_30` / `31_60` in `overdue_balance` (only 60-days-plus counts).181- Treating a past-due renewal as inside the renewal window (it is not; window is182 0–90 days in the **future**).183- Forgetting `retracted` NPS responses, or emitting `0` instead of `null` for184 missing NPS.185- Summing ARR across **all** accounts for `arr_at_risk` (exclude `low`).186- Forgetting to exact-match A/R `customer_name` to a CRM `legal_name` for link187 status (unmatched legal names like "…Subsidiary LLC" / "…Services" stay188 `unlinked` with `account_id: null`).189- Omitting the `policy_codes` block, or guessing its values instead of using190 `references/policy_codes.md`.