Skip to content

Developer Guide

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.

Extension Guide Apex Required Runtime-Safe Design

Before you add code, make sure the behavior truly cannot be expressed through:

  • a packaged standard action
  • tighter capability descriptions and schemas
  • backend configuration
  • a context provider instead of a new tool

That discipline matters. Many extension codebases become harder than necessary because teams jump to custom Apex before they have exhausted the simpler configuration seams.

Core Extension Points

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.

Choose The Right Extension Seam

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 taskcustom action
inject more relevant business context before the LLM callcontext provider
support a provider with a different request or response contractprovider adapter
change how conversation history is retainedmemory strategy
expose an existing admin-owned processFlow-backed capability

The wrong seam creates long-term friction. For example:

  • a custom action is the wrong place to solve missing record context that belongs in a context provider
  • a provider adapter is the wrong place to enforce business policy that belongs in capability design
  • a custom tool is often unnecessary when a packaged action plus better configuration would work

Suggested Workspace Areas

  • Directoryforce-app/
    • Directorymain/default/classes/
      • BaseAgentAction.cls
      • IAgentAction.cls
      • IAgentContextProvider.cls
      • BaseProviderAdapter.cls
      • ...your extensions...
    • Directorymain/default/classes/tests/
      • TestFactory.cls
  • Directoryseed-data/
    • Directorymain/default/classes/
      • ...reference examples...

Custom Actions

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.

When to build one

  • the packaged standard actions are too generic
  • you need custom business rules, orchestration, or calculations
  • you need an external integration with strict request shaping
  • you need richer output objects than a generic CRUD action provides

What a good custom action looks like

A good action is:

  • narrow in business purpose
  • explicit in inputs and outputs
  • aligned with user-mode security
  • safe under Salesforce callout and DML rules
  • easy to test through observable outcomes

Implementation pattern

  1. Extend BaseAgentAction.
  2. Override parseActionConfiguration() when you need typed backend config.
  3. Implement executeAction(Map<String, Object> params).
  4. Return ActionOutcome.success(...) or ActionOutcome.failure(...).
  5. Add an 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

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.

Good use cases

  • current record context for a page-scoped assistant
  • related records such as open cases, opportunities, or activities
  • user, team, or territory context
  • derived domain signals the model should always see

Implementation pattern

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

Provider Adapters

Use a custom provider adapter when the provider does not speak the OpenAI-compatible contract used by OpenAIProviderAdapter.

Implementation path:

  1. Extend BaseProviderAdapter.
  2. Implement the request-building and response-parsing methods.
  3. Register the adapter class name in 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:

  • how is the request formatted?
  • how is authentication applied?
  • how is the provider response interpreted?
  • how are safety or moderation results surfaced back into the runtime?

They should not become a hidden place where business rules accumulate.

Memory Strategies

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:

  • the existing memory models do not fit the interaction pattern
  • the problem is truly memory policy, not prompt quality or excessive context payload
  • you are prepared to test the effect on later turns, summaries, and runtime trace behavior

Security Expectations

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.

Testing Expectations

What to test

  • success path with realistic inputs
  • missing or malformed input handling
  • permission failures and security filtering
  • async behavior when applicable
  • returned result shape and message content
  • observable state changes in AgentExecution__c and ExecutionStep__c when the extension participates in runtime flow

Test shape

@IsTest
private 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.

Practical Guidance

  • keep capabilities single-purpose
  • make schemas narrow and explicit
  • keep result objects structured and readable
  • add correction guidance on input failures when it helps the model recover

Common Extension Mistakes

  • creating broad “do everything” actions that mix lookup, mutation, and external callouts
  • solving missing context with a new tool instead of a context provider
  • putting business policy inside a provider adapter
  • returning vague or unstructured error information
  • ignoring user-mode access because the custom path “works in tests”
  • adding custom code before proving the packaged actions cannot meet the need

Reading Order For New Contributors

If you are new to the codebase, a good order is:

  1. AgentExecutionService
  2. RuntimeRegistryService
  3. one runtime implementation
  4. LLMInteractionService
  5. OrchestrationService
  6. CapabilityExecutionService
  7. the relevant extension interface and base class

That order gives you the runtime shape first, so your extension is built to fit the framework rather than accidentally fighting it.

Continue