Writing Apex That Agents Call: Custom Actions for Agentforce
Agentforce agents call your Apex code. Not a user clicking a button. Not a flow clicking a button. An AI reasoning engine that decided your method was the right tool for the job. That changes how you design parameters, handle errors, and think about security.
The Interface: @InvocableMethod
Agentforce calls Apex actions through the @InvocableMethod annotation. This is the same mechanism that Flows use to call Apex. The difference is who is calling it. A Flow follows a predetermined path. An agent follows a reasoning loop and decides which action to invoke based on the user's question and the action's description.
// The minimum viable Agentforce action
public class GetAccountHealth {
@InvocableMethod(
label='Get Account Health Score'
description='Calculates a health score (0-100) for an account
based on open opportunities, recent cases, engagement level,
and payment history. Requires the Account ID. Returns the
score, risk level, and recommended actions.'
)
public static List<AccountHealthResult> getHealth(
List<AccountHealthRequest> requests
) {
// Implementation
}
}
The description parameter is the most important line of code in this class. Atlas reads this description to decide whether to invoke the action. If the description is vague ("Gets account information"), Atlas will use it at the wrong time. If it is precise ("Calculates a health score (0-100) for an account based on open opportunities, recent cases, engagement level, and payment history"), Atlas knows exactly when this action is relevant and what to expect from it.
Parameter Design for LLM Population
When an agent invokes your action, it populates the input parameters. The agent extracts parameter values from the conversation context. This means your parameter design directly affects whether the agent can successfully call your method.
// Input class: What the agent sends you
public class AccountHealthRequest {
@InvocableVariable(
label='Account ID'
description='The Salesforce Account ID (18-character).
Can also accept Account Name for lookup.'
required=true
)
public String accountId;
@InvocableVariable(
label='Time Period'
description='The lookback period for health calculation.
Options: "last_30_days", "last_90_days", "last_year".
Defaults to "last_90_days" if not specified.'
required=false
)
public String timePeriod;
}
// Output class: What you return to the agent
public class AccountHealthResult {
@InvocableVariable(label='Health Score' description='0-100 score')
public Integer healthScore;
@InvocableVariable(label='Risk Level' description='Low, Medium, High, Critical')
public String riskLevel;
@InvocableVariable(label='Summary' description='One paragraph summary of account health')
public String summary;
@InvocableVariable(label='Recommended Actions' description='Comma-separated list of recommended next steps')
public String recommendedActions;
@InvocableVariable(label='Open Opportunities' description='Count of open opportunities')
public Integer openOpportunities;
@InvocableVariable(label='Open Cases' description='Count of open support cases')
public Integer openCases;
@InvocableVariable(label='Days Since Last Activity' description='Days since last logged activity')
public Integer daysSinceLastActivity;
}
Key design decisions for agent-populated parameters:
Accept flexible inputs. The agent might have an Account ID from a previous query. Or it might have an Account Name from the user's message. Design your action to accept either and resolve internally. Requiring a precise 18-character ID when the user said "Acme Corp" means the agent needs an extra action call to look up the ID first. That is an extra turn, extra latency, and extra opportunity for failure.
Make parameters optional with sensible defaults. The timePeriod parameter defaults to "last_90_days" if not specified. The agent does not need to ask the user "What time period?" unless the user specifically mentioned one. Fewer required parameters means fewer turns to collect inputs.
Use descriptive enums in descriptions. Listing the valid options ("last_30_days", "last_90_days", "last_year") in the description tells the agent what values to populate. Without this, the agent might send "90 days" or "three months" or "quarterly" and your code would not recognize it.
Return structured data, not just text. The output class has separate fields for score, risk level, and recommendations. This allows the agent to selectively use parts of the response. If the user asked "Is Acme at risk?", the agent can focus on the riskLevel field. If they asked for the full picture, the agent can use all fields.
The Full Implementation
Here is the complete account health score action with all the patterns that matter for production Agentforce use.
public class GetAccountHealth {
@InvocableMethod(
label='Get Account Health Score'
description='Calculates a health score (0-100) for an account
based on open opportunities, recent cases, engagement level,
and payment history. Requires the Account ID or Account Name.
Returns the score, risk level, and recommended actions.'
)
public static List<AccountHealthResult> getHealth(
List<AccountHealthRequest> requests
) {
List<AccountHealthResult> results = new List<AccountHealthResult>();
for (AccountHealthRequest req : requests) {
try {
results.add(calculateHealth(req));
} catch (Exception e) {
// Never throw to the agent. Return error in result.
AccountHealthResult errorResult = new AccountHealthResult();
errorResult.healthScore = -1;
errorResult.riskLevel = 'Error';
errorResult.summary = 'Unable to calculate health score: '
+ e.getMessage();
errorResult.recommendedActions = 'Please verify the account '
+ 'exists and try again.';
results.add(errorResult);
}
}
return results;
}
private static AccountHealthResult calculateHealth(
AccountHealthRequest req
) {
// Resolve account ID from ID or name
Id accountId = resolveAccountId(req.accountId);
// Determine lookback period
Integer lookbackDays = getLookbackDays(req.timePeriod);
Date cutoffDate = Date.today().addDays(-lookbackDays);
// Query account data with security enforcement
Account acct = [
SELECT Id, Name, Industry, AnnualRevenue,
OwnerId, Owner.Name,
(SELECT Id, StageName, Amount, CloseDate
FROM Opportunities
WHERE IsClosed = false
WITH SECURITY_ENFORCED),
(SELECT Id, Status, Priority, CreatedDate
FROM Cases
WHERE CreatedDate >= :cutoffDate
WITH SECURITY_ENFORCED),
(SELECT Id, ActivityDate, Subject
FROM ActivityHistories
WHERE ActivityDate >= :cutoffDate
WITH SECURITY_ENFORCED
ORDER BY ActivityDate DESC
LIMIT 10)
FROM Account
WHERE Id = :accountId
WITH SECURITY_ENFORCED
LIMIT 1
];
// Calculate component scores
Integer oppScore = calculateOpportunityScore(acct.Opportunities);
Integer caseScore = calculateCaseScore(acct.Cases);
Integer engagementScore = calculateEngagementScore(
acct.ActivityHistories
);
// Weighted health score
Integer healthScore = (
(oppScore * 40) +
(caseScore * 30) +
(engagementScore * 30)
) / 100;
// Build result
AccountHealthResult result = new AccountHealthResult();
result.healthScore = healthScore;
result.riskLevel = getRiskLevel(healthScore);
result.openOpportunities = acct.Opportunities.size();
result.openCases = acct.Cases.size();
result.daysSinceLastActivity = getDaysSinceLastActivity(
acct.ActivityHistories
);
result.summary = buildSummary(acct, result);
result.recommendedActions = buildRecommendations(acct, result);
return result;
}
private static Id resolveAccountId(String input) {
// Try as ID first
if (input != null && input.length() >= 15) {
try {
return Id.valueOf(input);
} catch (Exception e) {
// Not a valid ID, try name lookup
}
}
// Look up by name
List<Account> accounts = [
SELECT Id FROM Account
WHERE Name = :input
WITH SECURITY_ENFORCED
LIMIT 1
];
if (accounts.isEmpty()) {
throw new AccountNotFoundException(
'No account found with ID or name: ' + input
);
}
return accounts[0].Id;
}
private static Integer getLookbackDays(String timePeriod) {
if (timePeriod == null || timePeriod == '') {
return 90; // Default
}
switch on timePeriod.toLowerCase() {
when 'last_30_days', '30' { return 30; }
when 'last_90_days', '90' { return 90; }
when 'last_year', '365' { return 365; }
when else { return 90; }
}
}
private static Integer calculateOpportunityScore(
List<Opportunity> opps
) {
if (opps == null || opps.isEmpty()) return 20; // No pipeline = low
Decimal totalAmount = 0;
Integer closingSoon = 0;
for (Opportunity opp : opps) {
totalAmount += opp.Amount != null ? opp.Amount : 0;
if (opp.CloseDate <= Date.today().addDays(30)) {
closingSoon++;
}
}
// Score based on pipeline health
Integer score = 50; // Baseline
if (totalAmount > 100000) score += 20;
if (totalAmount > 500000) score += 15;
if (closingSoon > 0) score += 15;
return Math.min(score, 100);
}
private static Integer calculateCaseScore(List<Case> cases) {
if (cases == null || cases.isEmpty()) return 90; // No cases = healthy
Integer openHigh = 0;
Integer openNormal = 0;
for (Case c : cases) {
if (c.Status != 'Closed') {
if (c.Priority == 'High' || c.Priority == 'Critical') {
openHigh++;
} else {
openNormal++;
}
}
}
Integer score = 90;
score -= (openHigh * 20); // Each high-priority case = -20
score -= (openNormal * 5); // Each normal case = -5
return Math.max(score, 0);
}
private static Integer calculateEngagementScore(
List<ActivityHistory> activities
) {
if (activities == null || activities.isEmpty()) return 10;
// Score based on recency and volume
Integer daysSinceLast = getDaysSinceLastActivity(activities);
Integer activityCount = activities.size();
Integer score = 50;
if (daysSinceLast <= 7) score += 30;
else if (daysSinceLast <= 30) score += 15;
else if (daysSinceLast > 60) score -= 20;
if (activityCount >= 5) score += 20;
else if (activityCount >= 2) score += 10;
return Math.min(Math.max(score, 0), 100);
}
private static Integer getDaysSinceLastActivity(
List<ActivityHistory> activities
) {
if (activities == null || activities.isEmpty()) return 999;
return Date.today().daysBetween(activities[0].ActivityDate) * -1;
}
private static String getRiskLevel(Integer score) {
if (score >= 75) return 'Low';
if (score >= 50) return 'Medium';
if (score >= 25) return 'High';
return 'Critical';
}
private static String buildSummary(
Account acct, AccountHealthResult result
) {
return acct.Name + ' has a health score of '
+ result.healthScore + '/100 (' + result.riskLevel + ' risk). '
+ result.openOpportunities + ' open opportunities, '
+ result.openCases + ' open cases, '
+ result.daysSinceLastActivity + ' days since last activity.';
}
private static String buildRecommendations(
Account acct, AccountHealthResult result
) {
List<String> recs = new List<String>();
if (result.daysSinceLastActivity > 30) {
recs.add('Schedule a check-in call (no activity in '
+ result.daysSinceLastActivity + ' days)');
}
if (result.openCases > 3) {
recs.add('Review open support cases ('
+ result.openCases + ' active)');
}
if (result.openOpportunities == 0) {
recs.add('Explore upsell opportunities (no active pipeline)');
}
if (result.healthScore < 25) {
recs.add('URGENT: Account at critical risk. Escalate to manager.');
}
return recs.isEmpty()
? 'Account is healthy. Continue current engagement cadence.'
: String.join(recs, ', ');
}
public class AccountNotFoundException extends Exception {}
}
Error Handling for Agent-Invoked Apex
This is where most implementations fail. When a human clicks a button and sees an error, they read the message and try again. When an agent gets an unhandled exception, the entire reasoning loop breaks. The agent either returns a generic error message to the user or, worse, tries a different action that is not appropriate.
The rule: never throw an unhandled exception from an agent action. Always catch, always return a structured error in the result object.
// Pattern: Error handling for agent actions
// BAD: Throws exception. Agent cannot recover.
public static List<Result> doThing(List<Request> requests) {
Account acct = [SELECT Id FROM Account WHERE Id = :requests[0].accountId];
// Throws QueryException if not found. Agent sees: "An error occurred."
}
// GOOD: Returns structured error. Agent can reason about it.
public static List<Result> doThing(List<Request> requests) {
Result result = new Result();
try {
List<Account> accounts = [
SELECT Id FROM Account
WHERE Id = :requests[0].accountId
WITH SECURITY_ENFORCED
LIMIT 1
];
if (accounts.isEmpty()) {
result.success = false;
result.errorMessage = 'Account not found with ID: '
+ requests[0].accountId
+ '. Please verify the Account ID is correct.';
return new List<Result>{ result };
}
// Proceed with logic...
result.success = true;
} catch (System.NoAccessException e) {
result.success = false;
result.errorMessage = 'Insufficient permissions to access '
+ 'this account. The current user may not have access.';
} catch (Exception e) {
result.success = false;
result.errorMessage = 'Unexpected error: ' + e.getMessage();
}
return new List<Result>{ result };
}
The error message matters. "Account not found" tells the agent nothing useful. "Account not found with ID: 001XX000003DGFF. Please verify the Account ID is correct." tells the agent what went wrong and suggests a recovery path. The agent might then ask the user to confirm the account name instead.
Security: WITH SECURITY_ENFORCED
Every SOQL query in an agent action must use WITH SECURITY_ENFORCED. This is not optional. Without it, the Apex code runs in system context and returns all fields regardless of the user's permissions. The Einstein Trust Layer enforces FLS at the retrieval level, but if your Apex bypasses FLS, the Trust Layer's protections are meaningless.
// WITHOUT security enforcement (dangerous):
Account acct = [SELECT Id, Name, AnnualRevenue, SSN__c FROM Account WHERE Id = :id];
// Returns SSN__c even if the agent user's profile has no access to it.
// The AI now has the SSN in its context. Trust Layer masking cannot help
// because the data was retrieved in system context.
// WITH security enforcement (correct):
Account acct = [SELECT Id, Name, AnnualRevenue, SSN__c FROM Account WHERE Id = :id WITH SECURITY_ENFORCED];
// Throws System.NoAccessException if user lacks access to ANY queried field.
// The SSN never enters the AI's context.
The WITH SECURITY_ENFORCED clause throws an exception if the running user lacks access to any field in the SELECT clause. This is actually a problem for agent actions because it is all-or-nothing. If the user has access to Name and AnnualRevenue but not SSN__c, the entire query fails.
The solution is to query only fields the running user is expected to have access to, or to use Security.stripInaccessible() for a more granular approach:
// Granular FLS enforcement
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE,
[SELECT Id, Name, AnnualRevenue, SSN__c FROM Account WHERE Id = :id]
);
List<Account> accounts = (List<Account>) decision.getRecords();
// accounts[0].SSN__c is stripped if user lacks access.
// The query does NOT throw. Other fields are returned normally.
Two-Layer Testing Strategy
Agent actions need two layers of tests. Layer one: unit tests for the Apex logic. Layer two: integration tests for the agent's ability to invoke the action correctly.
Layer 1: Standard Apex Unit Tests
@IsTest
private class GetAccountHealthTest {
@TestSetup
static void setupData() {
Account testAccount = new Account(
Name = 'Test Health Account',
Industry = 'Technology',
AnnualRevenue = 500000
);
insert testAccount;
// Create opportunities
insert new List<Opportunity>{
new Opportunity(
AccountId = testAccount.Id,
Name = 'Test Opp 1',
StageName = 'Prospecting',
Amount = 75000,
CloseDate = Date.today().addDays(30)
),
new Opportunity(
AccountId = testAccount.Id,
Name = 'Test Opp 2',
StageName = 'Negotiation',
Amount = 150000,
CloseDate = Date.today().addDays(15)
)
};
// Create cases
insert new Case(
AccountId = testAccount.Id,
Subject = 'Test Case',
Status = 'Open',
Priority = 'High'
);
// Create activity
insert new Task(
WhatId = testAccount.Id,
Subject = 'Test Call',
Status = 'Completed',
ActivityDate = Date.today().addDays(-5)
);
}
@IsTest
static void testHealthyAccount() {
Account acct = [SELECT Id FROM Account WHERE Name = 'Test Health Account'];
GetAccountHealth.AccountHealthRequest req =
new GetAccountHealth.AccountHealthRequest();
req.accountId = acct.Id;
req.timePeriod = 'last_90_days';
Test.startTest();
List<GetAccountHealth.AccountHealthResult> results =
GetAccountHealth.getHealth(
new List<GetAccountHealth.AccountHealthRequest>{ req }
);
Test.stopTest();
System.assertEquals(1, results.size());
AccountHealthResult result = results[0];
System.assertNotEquals(-1, result.healthScore, 'Should not be error');
System.assert(result.healthScore >= 0 && result.healthScore <= 100,
'Score should be 0-100');
System.assertNotEquals(null, result.riskLevel);
System.assertNotEquals(null, result.summary);
System.assertNotEquals(null, result.recommendedActions);
System.assertEquals(2, result.openOpportunities);
System.assertEquals(1, result.openCases);
}
@IsTest
static void testAccountByName() {
GetAccountHealth.AccountHealthRequest req =
new GetAccountHealth.AccountHealthRequest();
req.accountId = 'Test Health Account'; // Name instead of ID
Test.startTest();
List<GetAccountHealth.AccountHealthResult> results =
GetAccountHealth.getHealth(
new List<GetAccountHealth.AccountHealthRequest>{ req }
);
Test.stopTest();
System.assertEquals(1, results.size());
System.assertNotEquals(-1, results[0].healthScore);
}
@IsTest
static void testInvalidAccount() {
GetAccountHealth.AccountHealthRequest req =
new GetAccountHealth.AccountHealthRequest();
req.accountId = 'NonExistentAccount';
Test.startTest();
List<GetAccountHealth.AccountHealthResult> results =
GetAccountHealth.getHealth(
new List<GetAccountHealth.AccountHealthRequest>{ req }
);
Test.stopTest();
System.assertEquals(1, results.size());
System.assertEquals(-1, results[0].healthScore);
System.assertEquals('Error', results[0].riskLevel);
System.assert(results[0].summary.contains('Unable to calculate'));
}
@IsTest
static void testDefaultTimePeriod() {
Account acct = [SELECT Id FROM Account WHERE Name = 'Test Health Account'];
GetAccountHealth.AccountHealthRequest req =
new GetAccountHealth.AccountHealthRequest();
req.accountId = acct.Id;
// timePeriod intentionally left null
Test.startTest();
List<GetAccountHealth.AccountHealthResult> results =
GetAccountHealth.getHealth(
new List<GetAccountHealth.AccountHealthRequest>{ req }
);
Test.stopTest();
System.assertNotEquals(-1, results[0].healthScore);
}
}
Layer 2: Agent Integration Tests
Layer 2 is manual (for now). Salesforce does not yet offer a programmatic way to test Agentforce agents end-to-end. You test in Agent Builder's preview panel.
Agent Integration Test Cases:
==================================
Test 1: Direct ID Input
User: "What's the health of account 001XX000003DGFF?"
Expected: Agent calls GetAccountHealth with accountId = "001XX000003DGFF"
Verify: Response includes health score, risk level, recommendations
Test 2: Name Input (Agent Must Resolve)
User: "How is the Acme Corp account doing?"
Expected: Agent calls GetAccountHealth with accountId = "Acme Corp"
Verify: Action resolves name to ID internally. No extra action call needed.
Test 3: Implicit Time Period
User: "Acme Corp health score for the last year"
Expected: Agent calls GetAccountHealth with timePeriod = "last_year"
Verify: Data covers 365-day window
Test 4: Error Recovery
User: "Health score for XYZNONEXISTENT"
Expected: Agent calls GetAccountHealth, gets error result
Verify: Agent tells user account was not found. Asks for correction.
Test 5: Follow-Up Question
User: "What's the health of Acme Corp?"
Agent: [returns health score]
User: "What about their open cases?"
Expected: Agent uses data from previous result (openCases field)
OR calls a separate GetCaseDetails action
Verify: Agent does not re-call GetAccountHealth unnecessarily
Design Principles for Agent Actions
1. Descriptions are contracts.
Write them as if an LLM will read them. Because it will.
2. Accept flexible inputs. Resolve internally.
ID or Name. Full date or relative ("last month").
3. Never throw. Always return structured errors.
The agent cannot read a stack trace.
4. Enforce FLS. Always.
WITH SECURITY_ENFORCED or Security.stripInaccessible().
5. Return structured data with labeled fields.
Let the agent pick which fields to use in its response.
6. Keep actions focused. One action, one purpose.
A "GetEverythingAboutAccount" action is unusable.
"GetAccountHealth", "GetAccountCases", "GetAccountPipeline"
are individually invocable and composable.
7. Design for idempotency.
The agent might call the action twice (retry after timeout).
Read actions are naturally idempotent. Write actions need
deduplication logic.
8. Respect governor limits.
The action runs in the same transaction as the agent.
100 SOQL queries, 50,000 rows, 6MB heap. Bulkify queries.
An agent will typically invoke your action once, but design
for the List<Request> pattern anyway.
Agent actions are a new kind of API surface. Your consumer is not a developer reading documentation. It is an LLM reading descriptions and making probabilistic decisions. Design for that consumer, and your Agentforce agent will be reliable. Design for humans only, and the agent will misuse your actions in creative and frustrating ways. Need help building Agentforce actions? We write production Apex for AI agents.