Example: an Apex enrichment provider

Developer

Goal: put renewal risk on an Account’s payload. No field stores it and no metric computes it, because it combines a rollup, a date, and an activity gap.


Why this needs code

“Renewal risk” here means: a renewal inside 90 days, with no open opportunity, and no activity in the last month. Configuration can express each piece but not the conclusion. An agent handed three numbers derives the judgment itself, every time. A provider states it once.

1. Write the provider

global with sharing class RenewalRiskProvider implements ctxl.EntityEnrichmentProvider {

    global List<ctxl.EntityEnrichmentRegistry.EnrichmentItem> compute(Id recordId, SObject record,
            ctxl.EntityEnrichmentContext context) {
        List<ctxl.EntityEnrichmentRegistry.EnrichmentItem> items =
            new List<ctxl.EntityEnrichmentRegistry.EnrichmentItem>();

        // The entity already queried its configured fields — read them when they're
        // there. Keep the fallback: admins edit field lists you don't control.
        Boolean loaded = record != null
            && record.isSet('Renewal_Date__c') && record.isSet('LastActivityDate');
        SObject acct = loaded ? record
            : [SELECT Renewal_Date__c, LastActivityDate FROM Account
               WHERE Id = :recordId WITH USER_MODE LIMIT 1];

        Date renewal = (Date) acct.get('Renewal_Date__c');
        if (renewal == null) {
            return items;   // nothing to say — an empty list, never null
        }

        Integer daysToRenewal = Date.today().daysBetween(renewal);
        items.add(new ctxl.EntityEnrichmentRegistry.EnrichmentItem(
            'Days to Renewal', daysToRenewal, 'number'));

        Integer openDeals = [SELECT COUNT() FROM Opportunity
            WHERE AccountId = :recordId AND IsClosed = false WITH USER_MODE];

        Date lastTouch = (Date) acct.get('LastActivityDate');
        Integer daysQuiet = lastTouch == null ? 999 : lastTouch.daysBetween(Date.today());

        // The judgment, stated once
        if (daysToRenewal <= 90 && openDeals == 0) {
            items.add(new ctxl.EntityEnrichmentRegistry.EnrichmentItem('Renewal Risk',
                daysQuiet > 30
                    ? 'HIGH — renewal in ' + daysToRenewal + ' days, no open opportunity, '
                        + 'no activity in ' + daysQuiet + ' days'
                    : 'MEDIUM — renewal in ' + daysToRenewal + ' days with no open opportunity'));

            // Selection only: the engine keeps the Ids, discards these values,
            // and re-queries as the calling user
            items.add(ctxl.EntityEnrichmentRegistry.recordsItem(
                'Recent Closed-Lost Deals', 'Opportunity',
                [SELECT Id FROM Opportunity
                 WHERE AccountId = :recordId AND IsWon = false AND IsClosed = true
                 AND CloseDate >= LAST_N_MONTHS:12 WITH USER_MODE
                 ORDER BY CloseDate DESC]));
        }

        return items;
    }
}

Outside the package namespace, the class and its compute method must both be global. The engine resolves the class by name at assembly time and cannot reach a public one.

The code follows the Provider SPI rules:

  • WITH USER_MODE on every query. The engine re-queries record sets as the caller, but passes scalar values through exactly as you return them. A user-mode query is what keeps those honest.
  • isSet before querying. If the entity already fetched the fields, a second query is waste. The fallback stays because admins edit field lists.
  • Empty list, never null, when there’s no renewal date.
  • Items added independently. Days to Renewal appears whenever there’s a date; the risk item only when the conditions hold, so its absence is itself information.
  • Records return Id only. The engine re-queries as the caller; columns come from the Opportunity entity’s list curation.
  • Two queries, or three when the fallback fires. That is the ceiling worth keeping.

2. Register it

Go to Enrichments → New Enrichment → Apex Provider:

TabSetting
DetailsLabel “Renewal Risk”, Minimum level detailed, Meaning “Computed from renewal date, open pipeline, and activity recency. HIGH warrants same-week outreach.”
DefinitionProvider class — pick RenewalRiskProvider from the dropdown
Available ForAccount
Where UsedAccount 360

Save verifies the class exists, constructs with no arguments, and implements the interface, so a typo fails here rather than at the first render. Minimum level detailed is right: two queries per record belongs on the full 360, not on list rows.

3. Preview it

Click Preview on the Enrichments row to run it against a real record. Try three records:

  • No renewal date → nothing.
  • Renewing soon, no open deals → HIGH or MEDIUM.
  • Healthy → only Days to Renewal. A risk item here means the condition is wrong.

4. What the agent gets

**Renewal Risk:**
- **Days to Renewal:** 47
- **Renewal Risk:** HIGH — renewal in 47 days, no open opportunity, no activity in 62 days

**Recent Closed-Lost Deals (2):**
| Name | Amount | Stage | Close Date |
| --- | --- | --- | --- |
| Global Media Expansion | 45,000 | Closed Lost | 2026-02-14 |
| Global Media Add-on | 12,000 | Closed Lost | 2025-11-03 |

The agent reads one conclusion instead of three numbers. The Meaning you wrote doesn’t appear here. It reaches agents through Get Catalog, in the element’s entry, where a planner reads it before spending a call.

5. Test it

@isTest
private class RenewalRiskProviderTest {

    @isTest
    static void testHighRiskWhenRenewalNearAndNoOpenDeals() {
        Account acct = new Account(Name = 'Test', Renewal_Date__c = Date.today().addDays(45));
        Database.insert(acct, AccessLevel.USER_MODE);

        // null record exercises the fallback query — the path most likely to break
        List<ctxl.EntityEnrichmentRegistry.EnrichmentItem> items =
            new RenewalRiskProvider().compute(acct.Id, null, null);

        Boolean hasRisk = false;
        for (ctxl.EntityEnrichmentRegistry.EnrichmentItem item : items) {
            if (item.label == 'Renewal Risk') hasRisk = true;
        }
        Assert.isTrue(hasRisk, 'Renewal inside 90 days with no open deals is risky');
    }
}

When to use a provider

Do when the value requires a judgment, a callout, an external system, or logic configuration can’t express.

Don’t when a metric or formula would do. A metric shares one query with every other metric on the same child object and filter, a formula reads the record already loaded, and neither needs a deploy or a test class.

TIP

If you can describe the value as “count/sum X where Y”, it’s a metric. If describing it needs the words “and then”, it’s probably a provider.

What’s next