Example: an Apex enrichment provider

Developer

Goal: surface renewal risk on an Account — a signal no field stores and no metric can compute, 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 has to derive 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 we 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;
    }
}

The code follows the Provider SPI rules:

  • WITH USER_MODE on every query. Scalar values are included as returned — user-mode queries keep the payload honest.
  • isSet before querying. If the entity already fetched our 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. 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. A reasonable ceiling for a provider.

2. Register it

Go to Enrichments → New Enrichment → Apex Provider:

TabSetting
DetailsLabel “Renewal Risk”, minimum level detailed, instruction “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 — a typo fails here, not at the first render.

Minimum level detailed is right: this costs two queries per record and 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
_Computed from renewal date, open pipeline, and activity recency. HIGH warrants same-week outreach._

**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 judgment and the guidance arrive together — the agent doesn’t derive risk from three numbers, and it knows what to do about it.

5. Test it

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

    // 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');
}

Pass null for record — the fallback path is the one that breaks when someone edits a field list months later.

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 — those are batched or free, editable without a deploy, and need no 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