Provider SPI
Developer
EntityEnrichmentProvider adds computed context that an admin can then add to any entity. One interface, one method.
The interface
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. It may not contain what you need, so keep a fallback query |
context | What the engine knows at the call: level, objectApiName, elementName. Treat every field as optional, because later versions add fields |
Return items in render order. Return an empty list, never null, when there is nothing to say.
Your class needs a zero-argument constructor, because the engine calls newInstance(). 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 that 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 any shape a value list can’t carry |
| Records | A table in the target object’s curated list columns |
Records results
The engine keeps the IDs you return and re-queries them as the calling user.
| Rule | What happens |
|---|---|
Select Id only | The engine discards every other field you queried |
| No per-row computed values | Return them as separate value items instead |
| Rows must be saved records | A row with no ID drops out |
| Order survives | Rows render in the order you returned them |
| 25 rows maximum | Past that, the item comes back with truncated set |
Security
The engine re-queries records results as the calling user, so sharing and field permissions apply to every row. It passes scalar values straight through, so query WITH USER_MODE to keep data the caller can’t see out of the payload. 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, as opposed to 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. An absent 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;
}
}
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, which is the one most likely to break in production. Then use Preview on the Enrichments page to see it render against a real record.
Best practices
-
Read
recordbefore querying.SObject.isSetdistinguishes queried from queried-and-null. Keep the fallback query, because 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 a throw, 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 or sum where” is enough, because the engine batches those into aggregate queries.
Data Cloud
A provider can query DMOs the same way. CRM sharing doesn’t govern DMO reads, so every caller sees whatever the provider returns. Expose deliberately. A declarative Data 360 source is planned; an Apex provider is today’s path.