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_MODEon 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.isSetbefore 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
Idonly. The engine re-queries as the caller; columns come from the Opportunity entity’slistcuration. - Two queries, or three when the fallback fires. That is the ceiling worth keeping.
2. Register it
Go to Enrichments → New Enrichment → Apex Provider:
| Tab | Setting |
|---|---|
| Details | Label “Renewal Risk”, Minimum level detailed, Meaning “Computed from renewal date, open pipeline, and activity recency. HIGH warrants same-week outreach.” |
| Definition | Provider class — pick RenewalRiskProvider from the dropdown |
| Available For | Account |
| Where Used | Account 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.