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);
}
ParameterWhat it is
recordIdThe record being assembled into a payload
recordThe already-loaded parent SObject, populated with whatever fields the entity queried. May not contain what you need — fall back to your own query
contextWhat 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.

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 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.

ShapeRenders as
ValueA labeled scalar. The format hint drives currency symbols and date formatting
BlockPre-rendered markdown — for prose, or a shape a value list can’t carry
RecordsA 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.

RuleWhat happens
Select Id onlyThe engine discards every other field you queried
No per-row computed valuesReturn them as separate value items instead
Rows must be saved recordsA row with no ID drops out
Order survivesRows render in the order you returned them
25 rows maximumPast 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 — 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;
    }
}

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.

Best practices

  • Read record before querying. SObject.isSet distinguishes 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_failure issue — 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 — 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.

What’s next