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_MODEon every query. Scalar values are included as returned — user-mode queries keep the payload honest.isSetbefore 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
Idonly. The engine re-queries as the caller; columns come from the Opportunity entity’slistcuration. - Two queries. A reasonable ceiling for a provider.
2. Register it
Go to Enrichments → New Enrichment → Apex Provider:
| Tab | Setting |
|---|---|
| Details | Label “Renewal Risk”, minimum level detailed, instruction “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 — 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.