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.

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.

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

RuleDetail
Select Id onlyDisplay fields are discarded
No per-row computed valuesReturn them as separate value items instead
Rows must be persisted recordsNo ID means dropped
Order is preservedRows 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 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 — 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.

What’s next