Custom actions
Implement new business logic by extending BaseAgentAction and returning structured
ActionOutcome results.
Use this guide when metadata configuration is no longer enough and you need to extend the core framework with code.
The goal of extension is not just to “make the agent do more.” The goal is to add new behavior without breaking the runtime model that makes the framework governable, testable, and operable.
Before you add code, make sure the behavior truly cannot be expressed through:
That discipline matters. Many extension codebases become harder than necessary because teams jump to custom Apex before they have exhausted the simpler configuration seams.
Custom actions
Implement new business logic by extending BaseAgentAction and returning structured
ActionOutcome results.
Context providers
Supply dynamic business context through IAgentContextProvider implementations.
Provider adapters
Add non-default model providers by extending BaseProviderAdapter.
Memory strategies
Implement alternative memory behavior through IMemoryManager when the shipped strategies are
not enough.
The most important design choice is often not “how do I code this?” but “where should this behavior live?”
| If you need to… | Usually start with… |
|---|---|
| add business logic that performs a task | custom action |
| inject more relevant business context before the LLM call | context provider |
| support a provider with a different request or response contract | provider adapter |
| change how conversation history is retained | memory strategy |
| expose an existing admin-owned process | Flow-backed capability |
The wrong seam creates long-term friction. For example:
force-app/main/default/classes/BaseAgentAction.clsIAgentAction.clsIAgentContextProvider.clsBaseProviderAdapter.cls...your extensions...main/default/classes/tests/TestFactory.clsseed-data/main/default/classes/...reference examples...Custom actions are the primary way to add business-specific capability logic.
They are the right answer when the framework needs to perform a task that cannot be expressed cleanly through packaged actions or Flow-backed capabilities.
A good action is:
BaseAgentAction.parseActionConfiguration() when you need typed backend config.executeAction(Map<String, Object> params).ActionOutcome.success(...) or ActionOutcome.failure(...).AgentCapability__c record that points to the class.Minimal shape:
public with sharing class ActionAccountHealth extends BaseAgentAction { public class ConfigDTO { public Decimal threshold; }
private ConfigDTO config;
public override ActionOutcome executeAction(Map<String, Object> params) { Id accountId = (Id) params.get('accountId'); if (accountId == null) { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, 'accountId is required' ); }
try { Account accountRecord = [ SELECT Id, Name FROM Account WHERE Id = :accountId WITH USER_MODE LIMIT 1 ];
return ActionOutcome.success(new Map<String, Object>{ 'accountId' => accountRecord.Id, 'message' => 'Health score calculated successfully' }); } catch (Exception e) { return ActionOutcome.fromException(e); } }
@TestVisible protected override void parseActionConfiguration(String actionConfigurationJson, String logPrefix) { this.config = new ConfigDTO(); }}The action class should own business execution logic, not general runtime orchestration. Let the framework remain responsible for agent flow, tool selection, retries, and execution state.
Context providers inject relevant data before the model decides what to do.
In many cases, a context provider is a better extension than a new action because the real problem is not missing capability, but missing context at decision time.
public with sharing class AccountContextProvider implements IAgentContextProvider { public Map<String, List<SObject>> getContext( Set<Id> anchorIds, Id userId, String configurationJson ) { Map<String, List<SObject>> results = new Map<String, List<SObject>>();
if (anchorIds == null || anchorIds.isEmpty()) { return results; }
List<Case> openCases = [ SELECT Id, CaseNumber, Subject, Status FROM Case WHERE AccountId IN :anchorIds AND IsClosed = false WITH USER_MODE LIMIT 10 ];
if (!openCases.isEmpty()) { results.put('Open Cases', openCases); }
return results; }}The best context providers are selective. They provide the data the model actually needs, not a full data dump because “more context might help.”
Use a custom provider adapter when the provider does not speak the OpenAI-compatible contract used
by OpenAIProviderAdapter.
Implementation path:
BaseProviderAdapter.LLMConfiguration__c.ProviderAdapterClass__c.Keep provider adapters focused on transport and provider-contract behavior. Business policy, capability semantics, and user-facing instructions should stay outside the adapter.
Provider adapters should answer questions like:
They should not become a hidden place where business rules accumulate.
Most teams will use the shipped memory behavior, but the framework does expose IMemoryManager as
an extension seam when conversation retention needs differ from the defaults.
Reach for this only when:
User-mode data access
Use WITH USER_MODE for queries and prefer user-mode DML paths so framework extensions stay
aligned with Salesforce permissions.
Field-level protection
Use Security.stripInaccessible(...) and related platform checks for creates and updates.
Error semantics
Return meaningful error codes so the runtime can decide whether the LLM should retry, halt, or ask for correction.
Callout safety
Remember that DML before callout is a runtime constraint. Avoid introducing illegal callout paths in custom logic.
If an extension works functionally but bypasses these expectations, it is not a successful integration with the framework. It is a local workaround that will be expensive later.
AgentExecution__c and ExecutionStep__c when the extension participates in runtime flow@IsTestprivate class ActionAccountHealthTest { @IsTest static void testMissingAccountId() { ActionAccountHealth actionInstance = new ActionAccountHealth(); ActionContext context = new ActionContext( null, UserInfo.getUserId(), UserInfo.getUserId(), null, null, null, 'ActionAccountHealth', 'test-turn-1', 1, 'Conversational' );
ActionOutcome result = actionInstance.execute('{}', '{}', context);
System.assertEquals(false, result.isSuccess); System.assertEquals( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, result.errorCode ); }}When possible, test the extension in the way the runtime actually uses it, not only as an isolated utility. That gives you much better confidence that configuration, execution state, and error handling behave correctly together.
UNEXPECTED_ERROR for ordinary business failuresIf you are new to the codebase, a good order is:
AgentExecutionServiceRuntimeRegistryServiceLLMInteractionServiceOrchestrationServiceCapabilityExecutionServiceThat order gives you the runtime shape first, so your extension is built to fit the framework rather than accidentally fighting it.