Skip to main content
New tool CRON Expression Builder — preview next run times before you schedule Apex. Open the builder →
Diagram of a custom metadata record that Agentforce, Flow, Apex and validation rules all read
Apex

Custom Metadata for Agentforce: One Policy, Every Layer

Keep a business policy in one Custom Metadata Type so formulas, validation rules, before-save Flow, Apex and an Agentforce agent all read the same values. Agents need an invocable Apex bridge to see it, and two Tooling API queries show what breaks before you change a field.

The short answer

Put the policy values in one Custom Metadata Type and read it directly from formula fields, default values, validation rules, before-save Flow and Apex. Agentforce cannot read $CustomMetadata, so expose the same values through a bulk-safe @InvocableMethod that the agent calls before it answers.

Key takeaways Model each business policy as one Custom Metadata Type with a Default record plus one record per region, and keep who-may-override rules in a Custom Permission. Route every Apex read through a single class with a getInstance fallback to Default, and make the Agentforce invocable action a thin wrapper over that class. Use a formula-field bridge where a layer cannot read $CustomMetadata (Lightning App Builder visibility), and resolve values in Apex as bind variables where Winter '27 SOQL FORMULA() rejects them. Run the two Tooling API queries against MetadataComponentDependency before you rename or delete any policy field.

Finance lowers the maximum refund from 80% to 70%. That number lives in a validation rule, a flow decision, an Apex constant and the instructions of the service agent that now answers refund questions. Someone has to find all four, and the agent is the one people forget. Keep the value in one Custom Metadata Type that every layer reads, and the policy change becomes a record edit in Setup with no deployment.

Andrew Fawcett worked through this pattern on Andy in the Cloud with a Deal_Policy__mdt type for opportunity discounts. I've applied his design to a refund policy in a service org. Below is where each layer can read the values, where it can't, and how to check what depends on a field before you touch it.

Design one type per policy

Fawcett's type has three records (Default, EMEA, AMER) and four fields: Large Deal Amount, Max Discount %, Default Discount %, Approval Threshold %. The choice I'd copy first is that max discount holds the same value in every record, because it is an org-wide ceiling, while only the approval threshold changes by region. Layers that can only read a named record take the ceiling from Default, and regional values go to the layers that can choose a record at runtime.

My version is Refund_Policy__mdt with records Default, UK and US:

Field Type Default UK US
Auto_Approve_Limit__c Number(16,2) 100 150 200
Max_Refund_Percent__c Percent 80 80 80
Default_Refund_Percent__c Percent 50 50 50
Escalation_Days__c Number(3,0) 5 3 5

The consuming object is Case, with Refund_Region__c (a picklist whose values match the record DeveloperNames), Refund_Amount__c, Requested_Refund_Percent__c and Needs_Escalation__c.

I'd keep one type per policy and avoid a single "settings" type with forty unrelated fields. A small type is easier to permission and review, and impact analysis on it returns a short list. Like Fawcett, I'd put the question of who may exceed the policy in a Custom Permission (Override_Refund_Policy) and never in a metadata field. Custom Metadata holds the values automation runs on; Custom Permissions say which users may bypass them. If you are still choosing between Custom Metadata and Custom Settings for this kind of data, this comparison covers the trade-offs.

Declarative layers and before-save Flow

Formula fields, default values and validation rules all accept $CustomMetadata directly, and each has to name a specific record.

A formula field Is_Auto_Approvable__c (Checkbox) uses Refund_Amount__c <= $CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c. It can't switch to the UK record based on Refund_Region__c, because a formula can't choose a record at runtime, so treat this field as a coarse org-wide signal. The regional decision happens in Flow and Apex.

The default value of Requested_Refund_Percent__c is $CustomMetadata.Refund_Policy__mdt.Default.Default_Refund_Percent__c. The insert-field picker in the default value editor doesn't list $CustomMetadata, but the field accepts the reference if you type it by hand.

A validation rule Refund_Within_Ceiling uses Requested_Refund_Percent__c > $CustomMetadata.Refund_Policy__mdt.Default.Max_Refund_Percent__c and blocks the save with a message stating the ceiling. Because the ceiling is identical in every record, the rule never needs to know the region.

For regional values, a before-save record-triggered flow on Case (Set_Refund_Policy) does the lookup:

  1. Get Records on Refund_Policy__mdt where DeveloperName equals {!$Record.Refund_Region__c}, first record only.
  2. A formula resource varAutoApproveLimit returns the regional value, or the Default record's value if the lookup came back empty: IF(ISBLANK({!Get_Regional_Policy.Id}), {!$CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c}, {!Get_Regional_Policy.Auto_Approve_Limit__c}).
  3. A second formula resource, varNeedsEscalation, evaluates {!$Record.Refund_Amount__c} > {!varAutoApproveLimit} && NOT({!$Permission.Override_Refund_Policy}), and an Assignment writes it to Needs_Escalation__c.

With the $CustomMetadata fallback in place, a new region picklist value that has no matching record still gets the Default limit instead of a blank one. The Flow and Custom Metadata guide covers Get Records on metadata types in more detail.

Apex and the Agentforce bridge

One class for every Apex read

In Apex, call getInstance with the DeveloperName and fall back to Default when it returns null. I'd put this in exactly one class. Trigger handlers, batch jobs and the agent action all call it, and none of them query the type themselves.

public with sharing class RefundPolicy {
    public static final String DEFAULT_RECORD = 'Default';

    public static Refund_Policy__mdt forRegion(String region) {
        Refund_Policy__mdt policy = String.isBlank(region)
            ? null
            : Refund_Policy__mdt.getInstance(region);
        return policy != null
            ? policy
            : Refund_Policy__mdt.getInstance(DEFAULT_RECORD);
    }
}

Giving the agent the same numbers

Agentforce agents can't read $CustomMetadata references at all. There is an IdeaExchange idea asking for it; until it ships, the agent needs an action. Make that action a thin wrapper over the same class so the agent and the trigger can never disagree, and make it bulk-safe (List in, List out) even though an agent usually sends one request.

public with sharing class GetRefundPolicyForAgent {

    public class Request {
        @InvocableVariable(label='Region' description='Customer region code, for example UK or US. Blank uses Default.')
        public String region;
    }

    public class Result {
        @InvocableVariable(label='Applied Policy Record' description='DeveloperName of the policy record used')
        public String appliedRegion;
        @InvocableVariable(label='Auto Approve Limit')
        public Decimal autoApproveLimit;
        @InvocableVariable(label='Max Refund Percent')
        public Decimal maxRefundPercent;
        @InvocableVariable(label='Default Refund Percent')
        public Decimal defaultRefundPercent;
        @InvocableVariable(label='Escalation Days')
        public Integer escalationDays;
    }

    @InvocableMethod(label='Get Refund Policy' description='Returns current refund policy values for a region, falling back to the Default record.')
    public static List<Result> getPolicy(List<Request> requests) {
        List<Result> results = new List<Result>();
        for (Request req : requests) {
            Refund_Policy__mdt policy = RefundPolicy.forRegion(req.region);
            Result res = new Result();
            res.appliedRegion = policy.DeveloperName;
            res.autoApproveLimit = policy.Auto_Approve_Limit__c;
            res.maxRefundPercent = policy.Max_Refund_Percent__c;
            res.defaultRefundPercent = policy.Default_Refund_Percent__c;
            res.escalationDays = policy.Escalation_Days__c == null
                ? null
                : policy.Escalation_Days__c.intValue();
            results.add(res);
        }
        return results;
    }
}

Fawcett's agent sends policy questions to a dedicated subagent that calls his action before every answer and binds the outputs to Agent Script variables. I'd build mine the same way: a Refund_Desk agent hands refund questions to a refund_rules subagent whose instructions say to call Get Refund Policy first and quote only the bound values. That costs one extra action call per turn. I accept the cost, because the alternative is an agent answering from a number someone pasted into its instructions months ago, which is the drift this whole pattern exists to remove. Returning appliedRegion also lets the agent say when it fell back to Default. For more on structuring the action classes, see architecting Agentforce custom actions.

Where $CustomMetadata stops working

Layer Reads $CustomMetadata Picks record at runtime Workaround
Formula field Yes No, named record only Flow or Apex for regional values
Field default value Yes (type it, picker omits it) No None needed for org-wide values
Validation rule Yes No Keep ceilings in Default
Before-save Flow Yes, as fallback Yes, Get Records on DeveloperName Not needed
Apex Uses getInstance Yes Not needed
Agentforce agent No Via action @InvocableMethod bridge
Lightning App Builder visibility No No Formula-field bridge on record pages; Apex/@wire or Dynamic Interactions on App and Home pages
SOQL FORMULA() (Winter '27 beta) No No Bind variable from Apex

Long text area fields on a metadata type can't be used in $CustomMetadata references at all, and they are expensive to keep: each one counts as 255 characters toward the org's 10 million character Custom Metadata allotment, however short its content. Anything a formula or flow needs to reference belongs in Text, Number, Percent or Checkbox fields.

For component visibility, Is_Auto_Approvable__c already works as a bridge. On the Case record page, an "Approve Refund" component can filter on that checkbox and so reads the metadata indirectly.

The Winter '27 FORMULA() function in SOQL WHERE clauses is beta, needs API 68 or later, and works only in sandbox and scratch orgs. It compares fields on the same object and can't be used in SELECT:

SELECT Id, CaseNumber FROM Case
WHERE FORMULA('Refund_Amount__c - Order_Value__c') > 0

Put $CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c inside the formula string and the query fails with Invalid formula: no such field. Resolve the value in Apex first:

Decimal limitValue = RefundPolicy.forRegion('UK').Auto_Approve_Limit__c;
List<Case> overLimit = [
    SELECT Id, CaseNumber, Refund_Amount__c
    FROM Case
    WHERE Refund_Region__c = 'UK' AND Refund_Amount__c > :limitValue
];

What to watch for

  • An org can have up to 200 Custom Metadata Types (350 counting certified managed packages) and 100 fields per type. One type per policy is affordable; one type per value is not.
  • Process Builder still caps $CustomMetadata references at 15 per process. If old automation reads the policy, that is one more reason to finish moving it to Flow.
  • Apex has no per-transaction limit on Custom Metadata SOQL queries, but bulk reads still count toward the 50,000 rows returned per transaction.
  • Formulas, default values and validation rules read only the record they name. If someone later makes the ceiling vary by region, those three layers will keep enforcing Default without raising any error. I have seen this happen when a regional exception went in as a record edit and nobody realised the Default value was now enforced in some places and ignored in others.

Find every consumer before you change a field

Renaming or deleting a metadata field is where this pattern can hurt you. Setup's "Where is this used?" on the field is the quick check. From the CLI, get the field's Id through the Tooling API (DeveloperName has no __c suffix), then query its dependents:

sf data query -t --json -q "SELECT Id FROM CustomField WHERE EntityDefinition.QualifiedApiName = 'Refund_Policy__mdt' AND DeveloperName = 'Max_Refund_Percent'"

sf data query -t --json -q "SELECT MetadataComponentName, MetadataComponentType FROM MetadataComponentDependency WHERE RefMetadataComponentId = '<field Id from the first query>'"

When Fawcett ran this for his Max Discount Percent field, he got back the agent action class (LoadDealPolicyForAgent, ApexClass) and the validation rule (Discount_Within_Policy, ValidationRule). For my type I'd expect GetRefundPolicyForAgent and Refund_Within_Ceiling. Both queries go in the release checklist for any change to a policy type. Before deleting a field, I'd also search the flow and agent metadata in source control, because removing a field the agent action reads changes what the agent tells customers.

Originally reported by andyinthecloud.com

Frequently asked questions

Can Agentforce read custom metadata with $CustomMetadata directly?

No. Agents cannot resolve $CustomMetadata references, and there is an open IdeaExchange idea asking for it. Load the record in an @InvocableMethod and return the values as action outputs the agent binds to variables.

Can a formula field reference a custom metadata record dynamically by region?

No. A $CustomMetadata reference in a formula names one specific record, such as $CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c. For a per-record lookup, use Get Records in a before-save Flow or getInstance in Apex.

Does SOQL FORMULA() support $CustomMetadata in Winter '27?

No. The WHERE-clause FORMULA() feature is beta, needs API 68 or later, runs only in sandbox and scratch orgs, and rejects $CustomMetadata references with "Invalid formula: no such field". Read the value in Apex first and pass it in as a bind variable.

How to find all references to a custom metadata field before changing it?

Use "Where is this used?" on the field in Setup. From the CLI, query the field's CustomField Id with the Tooling API, then query MetadataComponentDependency where RefMetadataComponentId equals that Id.

Can $CustomMetadata reference a long text area custom metadata field?

No. Long text area fields are not supported in $CustomMetadata references, and each one counts as 255 characters toward the org's 10 million character Custom Metadata allotment whatever it holds.

Newsletter

One email every Tuesday

New guides, tool updates, and the release-note changes that break things.

No spam. Unsubscribe in one click.

Comments

Loading comments...

Leave a Comment