Agentforce agents are only as useful as the actions they can take. Out of the box, the standard actions cover CRM operations - creating records, running flows, querying data. When your use case requires calling an external API, executing complex business logic, or doing something outside the standard action library, you build a custom action. Custom actions are either Apex-backed (@InvocableMethod) or Flow-backed. This post focuses on the Apex path, because that’s where the interesting design decisions live.
What a Custom Action Actually Is
From the agent’s perspective, a custom action is a capability it can invoke when it determines the action is relevant to fulfilling a user request. The agent doesn’t read your code - it reads the descriptions you attach to the method, the parameters, and the output. Those descriptions are the interface between your code and the LLM’s reasoning process.
This is the most important mindset shift when writing custom actions: you’re writing documentation for an LLM, not an API contract for a developer.
The @InvocableMethod Requirements for Agentforce
The base requirements for any invocable method apply, plus Agentforce-specific requirements:
@InvocableMethodlabel is required (used as the action name in the UI)descriptionon@InvocableMethodis mandatory for Agentforce - this is what the LLM reads to decide when to call the action- Input parameters must use an inner class annotated with
@InvocableVariable, each with adescription - Output must also use an inner class with
@InvocableVariablefields with descriptions - The method signature must accept
List<InputClass>and returnList<OutputClass>
public class GetAccountHealthAction {
@InvocableMethod(
label='Get Account Health Score'
description='Retrieves the current health score and risk indicators for a given account. Use this when the user asks about account health, account risk, renewal likelihood, or customer satisfaction for a specific account.'
)
public static List<Output> execute(List<Input> inputs) {
List<Output> results = new List<Output>();
for (Input input : inputs) {
results.add(processRequest(input));
}
return results;
}
private static Output processRequest(Input input) {
Output out = new Output();
try {
Account acc = [
SELECT Id, Name, Health_Score__c, Risk_Level__c,
Last_NPS_Score__c, Open_Cases__c, ARR__c
FROM Account
WHERE Id = :input.accountId
LIMIT 1
];
out.success = true;
out.healthScore = (Integer) acc.Health_Score__c;
out.riskLevel = acc.Risk_Level__c;
out.summary = buildSummary(acc);
} catch (QueryException e) {
out.success = false;
out.errorMessage = 'Account not found. Please verify the account ID or name and try again.';
} catch (Exception e) {
out.success = false;
out.errorMessage = 'Unable to retrieve account health data at this time. The user can check the Account record directly in Salesforce.';
}
return out;
}
private static String buildSummary(Account acc) {
String risk = acc.Risk_Level__c != null ? acc.Risk_Level__c : 'Unknown';
Integer score = acc.Health_Score__c != null ? (Integer) acc.Health_Score__c : 0;
String trend = score >= 70 ? 'healthy' : score >= 40 ? 'at risk' : 'critical';
return String.format(
'{0} has a health score of {1}/100, is considered {2}, and has {3} open support cases.',
new List<Object>{ acc.Name, score, trend, (Integer) acc.Open_Cases__c }
);
}
public class Input {
@InvocableVariable(
label='Account ID'
description='The Salesforce record ID of the account to evaluate. Must be a valid 15 or 18 character Account ID.'
required=true
)
public String accountId;
}
public class Output {
@InvocableVariable(
label='Success'
description='True if the health data was retrieved successfully. False if an error occurred.'
)
public Boolean success;
@InvocableVariable(
label='Health Score'
description='Numeric health score from 0 to 100. Higher is better. Scores below 40 indicate critical risk.'
)
public Integer healthScore;
@InvocableVariable(
label='Risk Level'
description='Categorical risk level: Low, Medium, High, or Critical.'
)
public String riskLevel;
@InvocableVariable(
label='Summary'
description='A plain-language summary of the account health suitable for presenting to the user.'
)
public String summary;
@InvocableVariable(
label='Error Message'
description='If success is false, this explains what went wrong and suggests what the user can do next.'
)
public String errorMessage;
}
}
Designing Inputs and Outputs for LLM Consumption
The LLM will extract parameter values from the user’s natural language input. Design your input types to make this extraction reliable:
Prefer specific types over generic ones. Use String for IDs and names, Integer for counts, Boolean for flags. Avoid Object or Map<String, Object> - the LLM can’t reliably fill these.
Give parameters unambiguous names and descriptions. If a parameter is accountId, the description should clarify it’s a Salesforce record ID, not an external system ID. If both exist in your org, the distinction matters.
Keep input structures flat. Nested inner classes as @InvocableVariable types aren’t supported. If you need complex input, use multiple flat parameters.
Make outputs human-readable. Include a summary or message output field that’s a plain-language string the agent can present directly. Don’t make the LLM reassemble structured fields into a sentence - give it the sentence.
Error Handling: Give the Agent a Recovery Path
Errors are where most custom actions fall short. When an action throws an unhandled exception, the agent receives a generic failure message it can’t reason about. The result: a confused, unhelpful agent response.
Instead, catch all exceptions and return structured error information:
out.success = false;
out.errorMessage = 'The requested order could not be found. Ask the user to confirm the order number, or search for orders by customer name instead.';
Notice the error message suggests a next step. This gives the agent something actionable - it can tell the user what happened and offer an alternative path rather than just saying “an error occurred.”
Never return stack traces or raw exception messages. They’re meaningless to the LLM and confusing to the end user.
Security: Running User Context
Custom actions run in the context of the user interacting with the agent - not a system user, and not the org-wide context. This means:
- Sharing rules apply: the action can only see records the user has access to
- FLS applies: fields the user can’t read will return null even if queried
- Profile permissions apply: if the user’s profile doesn’t have access to an object, the query will throw
QueryException
This is the right security model. Don’t work around it with without sharing unless you’ve deliberately made the decision that a specific action should run with elevated access, and you understand the implications.
Testing with Agentforce Workbench
Before deploying a custom action to a live agent, test it in Agentforce Workbench (accessible from the Agent setup UI). This lets you invoke the agent in a controlled conversation and observe exactly which actions it chooses to call, with what parameters, and what it does with the output. It’s the fastest feedback loop for tuning your action descriptions.
If the agent consistently calls the wrong action, or passes incorrect parameter values, the fix is almost always in the description - not the code.