Developer Guide
Developer Guide
Build custom actions and context providers to extend the AI Agent Framework.
Developer Advanced
Suggested Workspace Paths
Directory
force-app/Directory
main/default/classes/BaseAgentAction.clsIAgentAction.cls...custom actions...
Directory
seed-data/Directory
main/default/classes/...reference examples...
Overview
The AI Agent Framework provides two primary extension points for developers:
- Custom Actions - Extend agent capabilities with custom business logic
- Agent Context Providers - Supply dynamic context data to agents during conversations
Both extension points follow consistent patterns for security, error handling, and integration with the framework.
How Custom Actions Fit in the Framework
sequenceDiagram participant User participant LLM as LLM Provider participant Orchestrator participant CapabilityService as CapabilityExecutionService participant Action as Your Custom Action participant SF as Salesforce Data
User->>LLM: "Calculate health score for Acme" LLM->>Orchestrator: Tool call: calculate_account_health Orchestrator->>CapabilityService: Execute capability CapabilityService->>Action: execute(config, args, context) Action->>Action: parseActionConfiguration() Action->>Action: executeAction(params) Action->>SF: Query/DML with USER_MODE SF-->>Action: Results Action-->>CapabilityService: ActionOutcome CapabilityService-->>Orchestrator: Tool result Orchestrator->>LLM: Format response LLM-->>User: "Acme's health score is 85..."How Context Providers Fit in the Framework
sequenceDiagram participant User participant Orchestrator participant ContextResolver as ContextResolverService participant Provider as Your Context Provider participant SF as Salesforce Data participant LLM as LLM Provider
User->>Orchestrator: Opens chat on Account page Orchestrator->>ContextResolver: Resolve context for Account ContextResolver->>Provider: getContext(accountIds, userId, config) Provider->>SF: Query related records (USER_MODE) SF-->>Provider: Cases, Opps, Tasks Provider-->>ContextResolver: {"Open Cases": [...], "Open Opps": [...]} ContextResolver-->>Orchestrator: Aggregated context Orchestrator->>LLM: System prompt + context Note over LLM: LLM now knows about<br/>open cases, opportunities, etc.Custom Actions
Custom actions allow agents to perform operations beyond the standard CRUD actions. Use them for complex business logic, external integrations, or specialized workflows.
When to Create a Custom Action
- Complex business logic that spans multiple objects
- Integration with external systems or APIs
- Custom validation or transformation logic
- Operations requiring specific transaction handling
- Specialized calculations or data processing
Architecture Overview
sequenceDiagram participant Framework as Framework Runtime participant Base as BaseAgentAction participant Action as YourCustomAction participant Config as ConfigDTO participant Args as ArgumentsDTO participant Outcome as ActionOutcome
Framework->>Base: execute(configJson, argsJson, context) Base->>Action: parseActionConfiguration(configJson) Action->>Config: load admin settings Base->>Action: executeAction(params) Action->>Args: parse runtime arguments Action-->>Base: success/failure result Base-->>Outcome: standardized ActionOutcome Outcome-->>Framework: return to orchestratorStep-by-Step Implementation
Step 1: Create the Apex Class
/** * @description * Custom action to calculate account health score based on multiple factors. * Demonstrates best practices for custom action implementation. */public class ActionCalculateAccountHealth extends BaseAgentAction {
private ConfigDTO config;
/** * DTO for admin-configured settings (Backend Configuration) * These values are set by admins when creating the capability */ public class ConfigDTO { public Decimal revenueWeight; public Decimal engagementWeight; public Decimal supportWeight; public String scoringModel; // 'standard' or 'enterprise' }
/** * DTO for AI-provided parameters at runtime * These are passed by the LLM when invoking the action */ public class ArgumentsDTO { public Id accountId; public Boolean includeHistory; public Integer lookbackDays; }
/** * Main execution method - implement your business logic here */ public override ActionOutcome executeAction(Map<String, Object> params) { // 1. Deserialize parameters into DTO ArgumentsDTO args = new ArgumentsDTO(); args.accountId = (Id) params.get('accountId'); args.includeHistory = params.containsKey('includeHistory') ? (Boolean) params.get('includeHistory') : false; args.lookbackDays = params.containsKey('lookbackDays') ? Integer.valueOf(params.get('lookbackDays')) : 90;
// 2. Validate required parameters if (args.accountId == null) { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, 'accountId is required' ); }
// 3. Check object-level permissions try { Utils.checkObjectPermission( Account.SObjectType, AccessType.READABLE ); } catch (Utils.ActionSecurityException e) { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_PERMISSION_DENIED, e.getMessage() ); }
// 4. Execute business logic try { HealthScoreResult result = calculateHealthScore(args); return ActionOutcome.success(result); } catch (Exception e) { System.debug(LoggingLevel.ERROR, '[ActionCalculateAccountHealth] Error: ' + e.getMessage()); return ActionOutcome.failure( AIAgentConstants.ERR_CODE_UNEXPECTED_ERROR, 'Failed to calculate health score: ' + e.getMessage() ); } }
/** * Parse admin configuration - called before executeAction */ @TestVisible protected override void parseActionConfiguration( String actionConfigurationJson, String logPrefix ) { this.config = new ConfigDTO();
if (String.isNotBlank(actionConfigurationJson)) { Map<String, Object> configMap = (Map<String, Object>) JSON.deserializeUntyped(actionConfigurationJson);
this.config.revenueWeight = configMap.containsKey('revenueWeight') ? (Decimal) configMap.get('revenueWeight') : 0.4; this.config.engagementWeight = configMap.containsKey('engagementWeight') ? (Decimal) configMap.get('engagementWeight') : 0.3; this.config.supportWeight = configMap.containsKey('supportWeight') ? (Decimal) configMap.get('supportWeight') : 0.3; this.config.scoringModel = (String) configMap.get('scoringModel'); } else { // Set defaults this.config.revenueWeight = 0.4; this.config.engagementWeight = 0.3; this.config.supportWeight = 0.3; this.config.scoringModel = 'standard'; } }
private HealthScoreResult calculateHealthScore(ArgumentsDTO args) { // Your business logic here Account acc = [ SELECT Id, Name, AnnualRevenue, NumberOfEmployees FROM Account WHERE Id = :args.accountId WITH USER_MODE LIMIT 1 ];
// Calculate score based on config weights Decimal score = 75.0; // Simplified example
return new HealthScoreResult( acc.Id, score, 'Account health score calculated successfully' ); }
/** * Result wrapper - provides structured data for LLM consumption */ public class HealthScoreResult { public String accountId; public Decimal healthScore; public String message; public Map<String, Object> metadata;
public HealthScoreResult(Id accId, Decimal score, String msg) { this.accountId = String.valueOf(accId); this.healthScore = score; this.message = msg; this.metadata = new Map<String, Object>{ 'accountId' => accId, 'score' => score, 'calculatedAt' => System.now() }; } }}Step 2: Create the Capability Record
In Salesforce, create an AgentCapability__c record:
| Field | Value |
|---|---|
| Capability Name | calculate_account_health |
| Description | Calculates a health score for an account based on revenue, engagement, and support metrics. Use when the user asks about account health, risk assessment, or customer scoring. |
| Implementation Type | Apex |
| Implementation Detail | ActionCalculateAccountHealth |
| AI Agent Definition | (Your agent) |
| Backend Configuration | {"revenueWeight": 0.4, "engagementWeight": 0.3, "supportWeight": 0.3, "scoringModel": "standard"} |
| Parameters | See JSON Schema below |
Parameters JSON Schema:
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string", "description": "The 18-character Salesforce Account ID" }, "includeHistory": { "type": "boolean", "description": "Include historical trend data in the response" }, "lookbackDays": { "type": "integer", "description": "Number of days to analyze (default: 90)" } }}Best Practices for Custom Actions
Security
// ✅ DO: Always check object permissionsUtils.checkObjectPermission(Account.SObjectType, AccessType.READABLE);
// ✅ DO: Use WITH USER_MODE in SOQLList<Account> accounts = [SELECT Id FROM Account WITH USER_MODE];
// ✅ DO: Use Security.stripInaccessible for DMLSObjectAccessDecision decision = Security.stripInaccessible( AccessType.CREATABLE, recordsToInsert);insert decision.getRecords();
// ❌ DON'T: Query without security enforcementList<Account> accounts = [SELECT Id FROM Account]; // Missing USER_MODEError Handling
// ✅ DO: Return structured errors with guidancereturn ActionOutcome.failureWithGuidance( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, 'Invalid date format provided', 'Expected format: YYYY-MM-DD. Example: 2024-03-15');
// ✅ DO: Use appropriate error codes// - ERR_CODE_INPUT_VALIDATION: Bad input from LLM// - ERR_CODE_PERMISSION_DENIED: Security/access issues// - ERR_CODE_RECORD_NOT_FOUND: Record doesn't exist// - ERR_CODE_DML_ERROR: Database operation failed// - ERR_CODE_CONFIG_ERROR: Admin configuration issue// - ERR_CODE_UNEXPECTED_ERROR: Catch-all for unknown errors
// ❌ DON'T: Throw unhandled exceptionsthrow new AuraHandledException('Something went wrong'); // Bad!Parameter Handling
// ✅ DO: Handle optional parameters with defaultsargs.lookbackDays = params.containsKey('lookbackDays') ? Integer.valueOf(params.get('lookbackDays')) : 90;
// ✅ DO: Validate required parameters earlyif (args.accountId == null) { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, 'accountId is required' );}
// ✅ DO: Use TypeCoercionService for SObject field coercionMap<String, Object> typedData = TypeCoercionService.coerceArgumentTypesForSObject( fieldData, Account.SObjectType, AccessType.CREATABLE);Result Structure
// ✅ DO: Include a 'message' field for user displaypublic class MyResult { public String message; // Framework uses this for display public Object data; public Map<String, Object> metadata;}
// ✅ DO: Provide rich metadata for LLM contextthis.metadata = new Map<String, Object>{ 'recordId' => recordId, 'operationType' => 'create', 'fieldsModified' => fieldList, 'timestamp' => System.now()};Agent Context Providers
Context providers supply dynamic, relevant data to agents during conversations. They enable agents to understand the current business context without explicit user queries.
When to Create a Context Provider
- Provide related records automatically (e.g., open cases for an account)
- Supply user-specific context (e.g., user’s team, territory, preferences)
- Aggregate data from multiple sources
- Apply business-specific filtering logic
- Provide computed or derived context
Architecture Overview
sequenceDiagram participant Orchestrator participant Resolver as ContextResolverService participant Provider as YourContextProvider participant Config as ProviderConfig participant Data as Salesforce Data participant Context as Context Map
Orchestrator->>Resolver: resolve(anchorIds, userId) Resolver->>Provider: getContext(anchorIds, userId, configJson) Provider->>Config: parseConfig(configurationJson) Provider->>Data: query related records (USER_MODE) Data-->>Provider: records Provider-->>Context: build labeled context blocks Context-->>Resolver: context payload Resolver-->>Orchestrator: aggregated contextStep-by-Step Implementation
Step 1: Create the Apex Class
/** * @description * Provides open cases and recent activities for accounts being discussed. * Demonstrates best practices for context provider implementation. */public class AccountContextProvider implements IAgentContextProvider {
/** * Main entry point - returns context data for the given anchor records * * @param anchorIds Set of record IDs to gather context for * @param userId The current user's ID * @param configurationJson Optional JSON configuration * @return Map of context labels to lists of SObjects */ 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; }
// Parse optional configuration ProviderConfig config = parseConfig(configurationJson);
// Filter to only Account IDs (provider may receive mixed types) Set<Id> accountIds = filterToAccountIds(anchorIds); if (accountIds.isEmpty()) { return results; }
// Gather open cases List<Case> openCases = queryOpenCases(accountIds, config.caseLimit); if (!openCases.isEmpty()) { results.put('Open Cases', openCases); }
// Gather recent activities List<Task> recentTasks = queryRecentTasks(accountIds, config.activityDays); if (!recentTasks.isEmpty()) { results.put('Recent Activities', recentTasks); }
// Gather open opportunities List<Opportunity> openOpps = queryOpenOpportunities(accountIds); if (!openOpps.isEmpty()) { results.put('Open Opportunities', openOpps); }
System.debug(LoggingLevel.INFO, '[AccountContextProvider] Returned ' + results.size() + ' context blocks for ' + accountIds.size() + ' accounts');
return results; }
/** * Filter IDs to only Account IDs */ private Set<Id> filterToAccountIds(Set<Id> allIds) { Set<Id> accountIds = new Set<Id>(); for (Id recordId : allIds) { if (recordId.getSObjectType() == Account.SObjectType) { accountIds.add(recordId); } } return accountIds; }
/** * Query open cases - always use WITH USER_MODE */ private List<Case> queryOpenCases(Set<Id> accountIds, Integer limitCount) { return [ SELECT Id, CaseNumber, Subject, Status, Priority, CreatedDate FROM Case WHERE AccountId IN :accountIds AND IsClosed = false WITH USER_MODE ORDER BY Priority DESC, CreatedDate DESC LIMIT :limitCount ]; }
/** * Query recent tasks */ private List<Task> queryRecentTasks(Set<Id> accountIds, Integer daysBack) { Date cutoffDate = Date.today().addDays(-daysBack); return [ SELECT Id, Subject, Status, ActivityDate, WhatId FROM Task WHERE WhatId IN :accountIds AND ActivityDate >= :cutoffDate WITH USER_MODE ORDER BY ActivityDate DESC LIMIT 10 ]; }
/** * Query open opportunities */ private List<Opportunity> queryOpenOpportunities(Set<Id> accountIds) { return [ SELECT Id, Name, StageName, Amount, CloseDate FROM Opportunity WHERE AccountId IN :accountIds AND IsClosed = false WITH USER_MODE ORDER BY CloseDate ASC LIMIT 5 ]; }
/** * Parse optional configuration JSON */ private ProviderConfig parseConfig(String configJson) { ProviderConfig config = new ProviderConfig();
if (String.isNotBlank(configJson)) { try { Map<String, Object> configMap = (Map<String, Object>) JSON.deserializeUntyped(configJson);
if (configMap.containsKey('caseLimit')) { config.caseLimit = Integer.valueOf(configMap.get('caseLimit')); } if (configMap.containsKey('activityDays')) { config.activityDays = Integer.valueOf(configMap.get('activityDays')); } } catch (Exception e) { System.debug(LoggingLevel.WARN, '[AccountContextProvider] Failed to parse config: ' + e.getMessage()); } }
return config; }
/** * Configuration holder with defaults */ private class ProviderConfig { public Integer caseLimit = 10; public Integer activityDays = 30; }}Step 2: Create User-Centric Provider (No Record Context)
/** * @description * Provides user-specific context like team members, territory, and preferences. * This provider does NOT require a record context - it uses the user ID as anchor. */public class UserContextProvider 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>>();
// For user-centric providers, anchorIds contains the userId // We use the userId parameter directly
// Get user details List<User> userDetails = [ SELECT Id, Name, Email, Department, Title, ManagerId, Manager.Name FROM User WHERE Id = :userId WITH USER_MODE LIMIT 1 ];
if (!userDetails.isEmpty()) { results.put('Current User', userDetails); }
// Get user's team (direct reports) List<User> teamMembers = [ SELECT Id, Name, Email, Title FROM User WHERE ManagerId = :userId AND IsActive = true WITH USER_MODE LIMIT 20 ];
if (!teamMembers.isEmpty()) { results.put('Direct Reports', teamMembers); }
return results; }}Step 3: Create the Configuration Record
Create an AgentContextConfig__c record:
| Field | Value |
|---|---|
| AI Agent Definition | (Your agent) |
| Implementation Type | Apex |
| Implementation Name | AccountContextProvider |
| Applicable SObject Types | Account |
| Requires Record Context | true |
| Is Active | true |
| Execution Order | 10 |
| Context Label | Account Context |
| Implementation Config JSON | {"caseLimit": 10, "activityDays": 30} |
For user-centric providers:
| Field | Value |
|---|---|
| Implementation Name | UserContextProvider |
| Requires Record Context | false |
| Applicable SObject Types | (leave blank) |
Best Practices for Context Providers
Security
// ✅ DO: Always use WITH USER_MODEList<Case> cases = [ SELECT Id, Subject FROM Case WHERE AccountId IN :accountIds WITH USER_MODE // Enforces FLS and sharing];
// ✅ DO: Handle empty results gracefullyif (!cases.isEmpty()) { results.put('Open Cases', cases);}// Don't add empty lists to results
// ❌ DON'T: Query without securityList<Case> cases = [SELECT Id FROM Case]; // Missing USER_MODE!Bulk Safety
// ✅ DO: Use Set<Id> for bulk-safe queriespublic Map<String, List<SObject>> getContext( Set<Id> anchorIds, // May contain multiple IDs Id userId, String configJson) { // Single query handles all IDs List<Case> cases = [ SELECT Id FROM Case WHERE AccountId IN :anchorIds // Bulk-safe WITH USER_MODE ];}
// ❌ DON'T: Query inside loopsfor (Id accountId : anchorIds) { List<Case> cases = [SELECT Id FROM Case WHERE AccountId = :accountId]; // This causes N+1 query problem!}Performance
// ✅ DO: Limit query resultsLIMIT 10 // Always set reasonable limits
// ✅ DO: Select only needed fieldsSELECT Id, CaseNumber, Subject, Status // Specific fields// Not: SELECT Id, ... (all fields)
// ✅ DO: Use indexed fields in WHERE clausesWHERE AccountId IN :accountIds // AccountId is indexed AND IsClosed = false AND CreatedDate >= :cutoffDate
// ✅ DO: Order results meaningfully for the agentORDER BY Priority DESC, CreatedDate DESCContext Labels
// ✅ DO: Use descriptive, consistent labelsresults.put('Open Cases', openCases);results.put('Recent Activities', recentTasks);results.put('Open Opportunities', openOpps);
// ✅ DO: Labels should describe the data clearly// The LLM uses these labels to understand the context
// ❌ DON'T: Use generic or unclear labelsresults.put('Data1', cases); // Unclearresults.put('stuff', tasks); // UnprofessionalTesting Custom Actions
@IsTestprivate class ActionCalculateAccountHealthTest {
@TestSetup static void setup() { Account testAccount = new Account( Name = 'Test Account', AnnualRevenue = 1000000 ); insert testAccount; }
@IsTest static void testSuccessfulExecution() { Account acc = [SELECT Id FROM Account LIMIT 1];
ActionCalculateAccountHealth action = new ActionCalculateAccountHealth();
// Create test context ActionContext ctx = new ActionContext( null, // executionId UserInfo.getUserId(), // originalUserId UserInfo.getUserId(), // executionUserId acc.Id, // relatedRecordId null, // agentDefinitionId null, // agentCapabilityId 'ActionCalculateAccountHealth', // implementationDetail 'test-turn-1', // turnIdentifier 1, // currentTurnCount 'Conversational' // executionType );
// Execute with configuration String configJson = '{"revenueWeight": 0.5, "scoringModel": "standard"}'; String argsJson = '{"accountId": "' + acc.Id + '", "includeHistory": true}';
Test.startTest(); ActionOutcome result = action.execute(configJson, argsJson, ctx); Test.stopTest();
System.assert(result.isSuccess, 'Action should succeed'); System.assertNotEquals(null, result.data, 'Should return data'); }
@IsTest static void testMissingRequiredParameter() { ActionCalculateAccountHealth action = new ActionCalculateAccountHealth();
ActionContext ctx = new ActionContext( null, UserInfo.getUserId(), UserInfo.getUserId(), null, null, null, 'ActionCalculateAccountHealth', 'test-turn-1', 1, 'Conversational' );
// Missing accountId String argsJson = '{"includeHistory": true}';
Test.startTest(); ActionOutcome result = action.execute('{}', argsJson, ctx); Test.stopTest();
System.assert(!result.isSuccess, 'Should fail without accountId'); System.assertEquals( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, result.errorCode ); }}Testing Context Providers
@IsTestprivate class AccountContextProviderTest {
@TestSetup static void setup() { Account testAccount = new Account(Name = 'Test Account'); insert testAccount;
Case testCase = new Case( AccountId = testAccount.Id, Subject = 'Test Case', Status = 'New' ); insert testCase; }
@IsTest static void testGetContext() { Account acc = [SELECT Id FROM Account LIMIT 1];
AccountContextProvider provider = new AccountContextProvider();
Test.startTest(); Map<String, List<SObject>> context = provider.getContext( new Set<Id>{ acc.Id }, UserInfo.getUserId(), '{"caseLimit": 5}' ); Test.stopTest();
System.assert(context.containsKey('Open Cases'), 'Should return Open Cases context'); System.assertEquals(1, context.get('Open Cases').size(), 'Should return 1 case'); }
@IsTest static void testEmptyAnchorIds() { AccountContextProvider provider = new AccountContextProvider();
Map<String, List<SObject>> context = provider.getContext( new Set<Id>(), UserInfo.getUserId(), null );
System.assert(context.isEmpty(), 'Should return empty map for empty anchor IDs'); }}Common Patterns
External API Integration Action
public class ActionWeatherLookup extends BaseAgentAction {
public override ActionOutcome executeAction(Map<String, Object> params) { String city = (String) params.get('city');
if (String.isBlank(city)) { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_INPUT_VALIDATION, 'city parameter is required' ); }
try { HttpRequest req = new HttpRequest(); req.setEndpoint('callout:Weather_API/current?city=' + EncodingUtil.urlEncode(city, 'UTF-8')); req.setMethod('GET');
Http http = new Http(); HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) { Map<String, Object> weatherData = (Map<String, Object>) JSON.deserializeUntyped(res.getBody()); return ActionOutcome.success(weatherData); } else { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_CONNECT_API_ERROR, 'Weather API returned status: ' + res.getStatusCode() ); } } catch (CalloutException e) { return ActionOutcome.failure( AIAgentConstants.ERR_CODE_CONNECT_API_ERROR, 'Failed to connect to Weather API: ' + e.getMessage() ); } }}Multi-Object Query Action
public class ActionGetCustomerOverview extends BaseAgentAction {
public override ActionOutcome executeAction(Map<String, Object> params) { Id accountId = (Id) params.get('accountId');
// Query multiple related objects Account acc = [ SELECT Id, Name, Industry, AnnualRevenue, (SELECT Id, Name, Email, Title FROM Contacts LIMIT 5), (SELECT Id, CaseNumber, Subject, Status FROM Cases WHERE IsClosed = false LIMIT 5), (SELECT Id, Name, StageName, Amount FROM Opportunities WHERE IsClosed = false LIMIT 5) FROM Account WHERE Id = :accountId WITH USER_MODE LIMIT 1 ];
return ActionOutcome.success(new CustomerOverview(acc)); }
public class CustomerOverview { public String accountName; public String industry; public Decimal annualRevenue; public List<Map<String, Object>> contacts; public List<Map<String, Object>> openCases; public List<Map<String, Object>> openOpportunities; public String message;
public CustomerOverview(Account acc) { this.accountName = acc.Name; this.industry = acc.Industry; this.annualRevenue = acc.AnnualRevenue; this.contacts = new List<Map<String, Object>>(); this.openCases = new List<Map<String, Object>>(); this.openOpportunities = new List<Map<String, Object>>();
// Transform related records for (Contact c : acc.Contacts) { this.contacts.add(new Map<String, Object>{ 'name' => c.Name, 'email' => c.Email, 'title' => c.Title }); }
for (Case cs : acc.Cases) { this.openCases.add(new Map<String, Object>{ 'caseNumber' => cs.CaseNumber, 'subject' => cs.Subject, 'status' => cs.Status }); }
for (Opportunity opp : acc.Opportunities) { this.openOpportunities.add(new Map<String, Object>{ 'name' => opp.Name, 'stage' => opp.StageName, 'amount' => opp.Amount }); }
this.message = 'Customer overview for ' + acc.Name; } }}Deployment Checklist
Custom Action Deployment
- Apex class extends
BaseAgentAction -
executeAction()method implemented -
parseActionConfiguration()overridden if needed - Security checks implemented (CRUD, FLS)
- Error handling returns proper
ActionOutcome - Unit tests with >75% coverage
-
AgentCapability__crecord created - Parameters JSON Schema defined
- Backend Configuration JSON defined (if needed)
- Capability description is clear and specific
Context Provider Deployment
- Apex class implements
IAgentContextProvider -
getContext()method implemented - All SOQL uses
WITH USER_MODE - Bulk-safe queries (no queries in loops)
- Reasonable LIMIT clauses
- Unit tests with >75% coverage
-
AgentContextConfig__crecord created -
ApplicableSObjectTypes__cconfigured correctly -
RequiresRecordContext__cset appropriately -
ExecutionOrder__cset for proper sequencing
Troubleshooting
Action Not Executing
- Check
AgentCapability__c.IsActive__cistrue - Verify
ImplementationType__cisApex - Confirm
ImplementationDetail__cmatches class name exactly - Check debug logs for instantiation errors
Permission Errors
- Verify user has CRUD on target objects
- Check Field-Level Security settings
- Review sharing rules
- Ensure
WITH USER_MODEis used in queries
Context Not Loading
- Check
AgentContextConfig__c.IsActive__cistrue - Verify
ApplicableSObjectTypes__cincludes the record type - Confirm
RequiresRecordContext__cmatches your use case - Check
ExecutionOrder__cfor proper sequencing - Review debug logs for provider errors
LLM Not Using Action
- Improve capability description - be specific about when to use
- Add examples in the description
- Verify Parameters JSON Schema is correct
- Check that parameter descriptions are clear
- Lower LLM temperature for more predictable behavior