Provider SPI
Developer
EntityEnrichmentProvider adds computed context that an admin can then add to any entity. One interface, one method.
The contract
global interface ctxl.EntityEnrichmentProvider {
List<ctxl.EntityEnrichmentRegistry.EnrichmentItem> compute(Id recordId, SObject record,
ctxl.EntityEnrichmentContext context);
}
| Parameter | What it is |
|---|---|
recordId | The record being assembled into a payload |
record | The already-loaded parent SObject, populated with whatever fields the entity queried. May not contain what you need — fall back to your own query |
context | What the engine knows at the call: level, objectApiName, elementName. New fields arrive in later versions — treat every field as optional |
Return items in render order. Return an empty list, never null, when there is nothing to say.
The engine constructs your class with newInstance(), so it needs a zero-argument constructor — the implicit one is fine. Outside the package namespace, the class and its compute method must be global.
Registering
Go to Studio → Enrichments → New Enrichment → Apex Provider. The dropdown lists every class in the org implementing the interface.
Save verifies the class exists, constructs with no arguments, and implements the interface — a typo fails the save, not the first render.
EnrichmentItem
global class EnrichmentItem {
global String label; // agent-facing — write it the way you'd want it cited
global Object value;
global String format; // 'currency'|'date'|'number'|'percentage'|'boolean'
global EnrichmentItem(String label, Object value);
global EnrichmentItem(String label, Object value, String format);
}
Two more shapes, as static factories on EntityEnrichmentRegistry:
EnrichmentItem blockItem(String label, List<String> lines)
EnrichmentItem recordsItem(String label, String targetObject, List<SObject> records)
One provider can mix all three in a single return list.
| Shape | Renders as |
|---|---|
| Value | A labeled scalar. The format hint drives currency symbols and date formatting |
| Block | Pre-rendered markdown — for prose, or a shape a value list can’t carry |
| Records | A table in the target object’s curated list columns |
Records results
The engine treats returned rows as an ID list: it discards your field values, re-queries the IDs as the calling user, and renders the target object’s curated list columns.
| Rule | Detail |
|---|---|
Select Id only | Display fields are discarded |
| No per-row computed values | Return them as separate value items instead |
| Rows must be persisted records | No ID means dropped |
| Order is preserved | Rows cap at 25, with a truncation flag |
Security
The engine re-checks records results. Scalar values are included as returned — query WITH USER_MODE so a payload never contains data the caller couldn’t see. See Security.
A worked provider
global with sharing class RenewalSignalsProvider 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. isSet tells you whether a field was queried, vs merely null.
Boolean loaded = record != null && record.isSet('AnnualRevenue')
&& record.isSet('LastActivityDate');
SObject acct = loaded ? record
: [SELECT AnnualRevenue, LastActivityDate FROM Account
WHERE Id = :recordId WITH USER_MODE LIMIT 1];
if (acct.get('AnnualRevenue') != null) {
items.add(new ctxl.EntityEnrichmentRegistry.EnrichmentItem(
'Annual Revenue', acct.get('AnnualRevenue'), 'currency'));
}
// Skip what you can't compute — the absence of an item is itself information
Date lastTouch = (Date) acct.get('LastActivityDate');
if (lastTouch != null) {
items.add(new ctxl.EntityEnrichmentRegistry.EnrichmentItem(
'Days Since Last Touch', lastTouch.daysBetween(Date.today()), 'number'));
}
// Selection only: the engine keeps the Ids, discards your values, and
// re-queries as the calling user
items.add(ctxl.EntityEnrichmentRegistry.recordsItem('Stalled Deals', 'Opportunity',
[SELECT Id FROM Opportunity WHERE AccountId = :recordId AND IsClosed = false
AND LastActivityDate < LAST_N_DAYS:30 WITH USER_MODE ORDER BY Amount DESC]));
return items;
}
}
Best practices
-
Read
recordbefore querying.SObject.isSetdistinguishes queried from queried-and-null. Keep the fallback query — admins edit field lists you don’t control. -
Self-configure from the record’s type so one provider serves every object in the element’s Available For list:
String objectApiName = recordId.getSObjectType().getDescribe().getName(); String activityFk = objectApiName == 'Contact' || objectApiName == 'Lead' ? 'WhoId' : objectApiName == 'User' ? 'OwnerId' : 'WhatId'; -
Degrade item by item. Skip what you can’t compute; an absent item is itself information.
-
Fail loudly. The engine catches throws, drops your items, and records an
element_failureissue — the payload survives. Don’t swallow exceptions. -
Keep to two or three queries. Assembly may run several enrichments per request. Prefer a metric where “count/sum where” suffices — those are batched.
Data Cloud
A provider can query DMOs the same way. DMO reads are not governed by CRM sharing, so every caller sees what it returns — expose deliberately. A declarative Data 360 source is planned; an Apex provider is today’s supported path.
Testing
Instantiate and call compute directly — it’s a plain class:
List<ctxl.EntityEnrichmentRegistry.EnrichmentItem> items =
new RenewalSignalsProvider().compute(accountId, null, null);
Assert.isFalse(items.isEmpty());
Passing null for record exercises the fallback query path — the one most likely to break in production. Then use Preview on the Enrichments page to see it render against a real record.