all insights

Custom Apex actions for Agentforce: the description is the code that matters most

You annotate a method, wire it into Agentforce Builder, and the agent still won't call it — or calls it with garbage. Custom Apex actions are how an agent does anything standard actions can't, but the part that decides whether they work isn't the Apex. It's the description Atlas reads, the permission set the agent user is missing, and the governor limits a reasoning loop can blow through. Here's the whole build.

Custom Apex actions for Agentforce: the description is the code that matters most — article illustration

You write a clean Apex class, annotate it, add it to your agent, and ask the agent to use it. Nothing happens — the agent talks around the task, or worse, invents an answer instead of calling your method. Or it calls the method but passes it something nonsensical. The Apex compiled fine. The unit test passes. And the action still doesn’t work, because the part of a custom agent action that decides whether an agent uses it correctly isn’t the code — it’s the metadata around the code that the reasoning engine reads to decide what your method is for.

Custom Agentforce actions in Apex are the escape hatch for everything the standard and Flow actions can’t do: a risk calculation, a call to a proprietary system, multi-object logic, heavy JSON parsing. When you build a service agent, you wire up topics and actions, and most actions are declarative. But eventually a topic needs something only code can do, and you reach for @InvocableMethod. This post is the full build — the annotation mechanics, the descriptions that actually drive behavior, the permission that silently breaks everything, and the governor limits a reasoning loop will happily exhaust — written for the person who’s already comfortable in Apex and keeps hitting the parts the Trailhead module glosses over.

The mechanics: an invocable method, wrapped for bulk

An Agentforce action is an Apex @InvocableMethod. The requirements are specific and non-negotiable: the method must be static, public or global, live in an outer class (not an inner class), and — the part people skip — take a List<T> as input and return a List<T> as output. The list-in, list-out shape exists for bulkification: a single user turn can generate multiple requests (ask the agent to apply a discount across five orders and it passes an array), and each element of your returned list must correspond to the same-index element of the input list.

The inputs and outputs are inner classes whose fields carry @InvocableVariable. One class describes the request, one describes the response. A note that trips people up on the way in: Apex annotation attributes are space- or newline-separated, not comma-separated. Here’s a complete, deployable action that returns a summary of an account’s open cases — built the way you should build every one, as a thin entry point over a service class:

public with sharing class GetAccountOpenCasesAction {

    // Request wrapper — one per invocation in the bulk list
    public class Request {
        @InvocableVariable(
            label='Account Id'
            description='The 15- or 18-character record Id of the Account to check open cases for.'
            required=true)
        public Id accountId;
    }

    // Response wrapper — one returned per request, same order
    public class Response {
        @InvocableVariable(
            label='Open Case Count'
            description='Number of open (not closed) cases for the account.')
        public Integer openCaseCount;

        @InvocableVariable(
            label='Case Summaries'
            description='Short human-readable summary of each open case: number, subject, priority.')
        public List<String> caseSummaries;

        @InvocableVariable(
            label='Error Message'
            description='Populated only on failure; empty string on success.')
        public String errorMessage;
    }

    @InvocableMethod(
        label='Get Open Cases for Account'
        description='Returns the count and a short summary of the open cases for a given Account. Use this when a user asks how many open cases or support tickets an account currently has. Do not use this to create, close, or edit cases.'
        category='Customer Support')
    public static List<Response> getOpenCases(List<Request> requests) {
        List<Response> responses = new List<Response>();
        for (Request req : requests) {
            responses.add(OpenCaseService.build(req)); // logic lives elsewhere
        }
        return responses;
    }
}
public with sharing class OpenCaseService {
    public static GetAccountOpenCasesAction.Response build(
            GetAccountOpenCasesAction.Request req) {
        GetAccountOpenCasesAction.Response res = new GetAccountOpenCasesAction.Response();
        res.caseSummaries = new List<String>();
        try {
            List<Case> cases = [
                SELECT CaseNumber, Subject, Priority
                FROM Case
                WHERE AccountId = :req.accountId AND IsClosed = false
                ORDER BY CreatedDate DESC
                LIMIT 50
            ];
            res.openCaseCount = cases.size();
            for (Case c : cases) {
                res.caseSummaries.add(
                    c.CaseNumber + ' — ' + c.Subject + ' (' + c.Priority + ')');
            }
            res.errorMessage = '';
        } catch (Exception e) {
            res.openCaseCount = 0;
            res.errorMessage = 'Unable to retrieve cases: ' + e.getMessage();
        }
        return res;
    }
}

The thin entry point pattern — the invocable method is a gateway that loops and delegates, with the real logic in a service class — isn’t stylistic. It keeps the business logic unit-testable in isolation, reusable outside the agent, and free of the list-wrangling boilerplate. Put a SOQL query and a for-loop directly in the invocable method and you’ve coupled your logic to the agent framework for no reason.

The description is a system prompt, not documentation

Now the part that actually decides whether the agent behaves. The Atlas reasoning engine doesn’t read your Apex. It reads the label and description on your @InvocableMethod and on every @InvocableVariable, and it uses that text to decide when to call the action and how to fill in the inputs. Salesforce’s own guidance is blunt about it: treat the description like a system prompt — precise, action-oriented, and explicit about what the method does and does not do.

That “does not do” clause is the one people leave off, and it’s why so many actions misfire. Look at the description in the example above: it says what the action is for (“how many open cases”) and fences off what it isn’t for (“do not use this to create, close, or edit cases”). Without that fence, the reasoning engine sees an action that mentions “cases” and may reach for it when the user wants something else entirely. The classic symptom of a description problem is an agent that hesitates — if it’s taking a long time to pick an action, or picking the wrong one, your descriptions are usually ambiguous or overlapping across actions, not your logic.

The agent will never see your code. It sees a sentence describing your code. If two actions have sentences that sound alike, the agent has to guess between them — and it will guess wrong on exactly the inputs you didn’t test.

The same rule applies at the variable level. Every @InvocableVariable description tells the engine what that input means and what format it expects, and — for outputs — what the returned value represents. When you register the action in Builder, those descriptions pre-fill the input and output instructions, so vague variable descriptions become vague agent behavior. Describe the expected format (“15- or 18-character record Id”), not just the field name.

Wiring it into the agent, and the permission that breaks it

Registering the class in Agentforce Builder is mechanical: add a new action, set Reference Action Type to Apex, Reference Action Category to Invocable Method, and pick your method. The inputs and outputs populate from your invocable variables, you tune the instructions, and you assign the action to a topic, which belongs to the agent. (As of the Winter ‘26 wave there’s also a newer path — exposing existing @AuraEnabled methods as agent actions — but for purpose-built agent logic, an explicit invocable action with deliberate descriptions is still the controllable choice.)

Then it doesn’t run, and here is the single most common reason: the agent user doesn’t have access to your Apex class. Every agent executes as a dedicated agent user with its own profile and permission sets, and that user needs Apex Class Access to your action class — granted through a permission set — plus object and field permissions for everything the class queries or writes. Miss the class access and the action silently fails to fire. Miss an object permission on something the query touches and you get an access error mid-conversation (a cross-reference-entity failure is the tell that the agent user can see the parent object but not a related one).

This is the same principle we hammer on in the agent fleet governance guide and the least-privilege security piece: the agent runs as an identity, and that identity’s permissions are the agent’s capabilities. Build the permission set as part of the action — Apex Class Access plus the exact object and field grants the logic needs, nothing more — and assign it to the agent user before you test. Treating permissions as an afterthought is why a working class looks broken.

Governor limits, because it’s still Apex

An agent action is Apex, so it runs under the same synchronous Apex governor limits as everything else — on the order of 100 SOQL queries, 150 DML statements, 10 seconds of CPU, and a 6 MB heap per transaction. What makes this sharper for agents than for, say, a trigger is that the reasoning engine can call your action more than once in a single user turn as it plans, acts, and re-evaluates. An action that’s careless with queries — one that would be fine called once — can blow the limit when the loop calls it three times, and you get a runtime exception surfaced awkwardly to a customer mid-chat.

Two disciplines keep you clear, and they compound:

  • Query and return lean. Select only the fields you need, filter hard, cap with LIMIT. This isn’t only about CPU and heap — the data you return becomes context the LLM has to read, and a bloated return both costs limits and dilutes the model’s focus. Return the three fields the agent needs, not the whole record. The governor-limit patterns that keep bulk Apex safe apply directly here.
  • Handle errors through the response, not exceptions. This is the one most Apex developers get backwards for agents. Don’t let the method throw — catch the failure and return a structured error field (the errorMessage in the example). A thrown exception gives the reasoning engine nothing to reason about, and a silent failure is dangerous because the agent may fabricate a confident answer in the vacuum. Hand the engine a clear, non-technical error string and it can tell the user something true instead of something invented.

Apex or Flow, and how to test either

Not every custom action should be Apex. Flow actions are faster to build, admin-maintainable, and fine for multi-step record operations. Reach for Apex when you need real computation, integration with a proprietary system, multi-object processing, or heavy JSON handling — the places where Flow gets brittle. Reach for Flow when an admin could own the logic and the data shapes are simple. The broader “which tool” call is the same one in our Flow, Apex, or Agentforce decision guide; for actions specifically, the tiebreaker is usually data complexity and whether a developer or an admin should own the change.

Testing has two layers, and you need both. The thin entry point means your service class is a normal Apex unit test — build the request, call the method, assert on the response, wrapped in Test.startTest()/Test.stopTest(). That proves the logic. It does not prove the agent will choose the action correctly, which is a description problem, not a logic problem — and that’s what the Agentforce testing tooling is for. Use Builder’s preview to watch the agent’s plan and see which action it picked and why; use the Testing Center to run conversation-level cases. An action can have 100% Apex coverage and still never get called, because coverage tests the code and the agent tests the sentence describing it. If you need the agent to call the action the same way every time regardless of phrasing, that determinism question is its own topic — see Agent Script.

What to actually do

Build the Apex the boring, correct way: a thin @InvocableMethod that loops over a List<Request> and delegates to a service class, list-in and list-out for bulk safety, lean queries, and errors returned as data rather than thrown. Then spend real effort on the part that isn’t code — write the method and variable descriptions like a system prompt, stating what the action is for and explicitly what it’s not for, so the reasoning engine can tell your action apart from the others. Ship a permission set with the action that grants the agent user Apex Class Access and exactly the object and field permissions the logic touches, and assign it before you test. Unit-test the service class for correctness and preview the agent for selection. Do that and the action fires when it should, with the right inputs, and fails legibly when it can’t.

The mental shift that makes custom actions click is this: you’re not writing a method for a user to call, you’re writing a capability for a reasoning engine to choose. The code has to be correct, but the description has to be persuasive and precise, and the permissions have to be exact — and when an action misbehaves, the bug is far more often in one of those two than in the Apex you spent your time on. If you’re extending an agent with custom actions and want the code, the descriptions, and the permission model right the first time, talk to us — building governed agents that take real, scoped actions is exactly the work we do.

Understanding the basics

How do you build a custom Agentforce action in Apex?

Write a public or global static method in an outer class, annotate it @InvocableMethod with a label and a description, and have it take a List<T> request and return a List<T> response (the list shape supports bulk invocation). Define the request and response as inner classes whose fields carry @InvocableVariable with their own labels and descriptions. Keep the invocable method a thin gateway that delegates to a service class. Then register it in Agentforce Builder with Reference Action Type = Apex and Category = Invocable Method, and assign it to a topic. Critically, grant the agent user Apex Class Access and the needed object/field permissions via a permission set, or the action will silently fail to run.

Why isn’t my Agentforce Apex action being triggered?

The two usual causes are the description and the permissions, not the code. The Atlas reasoning engine decides whether to call an action from the @InvocableMethod and @InvocableVariable descriptions, so if they’re vague or overlap with another action’s, the agent won’t reliably pick it — write them like a system prompt that states what the action does and does not do. The other cause is access: the agent runs as a dedicated agent user that needs Apex Class Access to your class (plus permissions on every object and field it touches) through a permission set. A class that compiles and passes tests will still do nothing in the agent if that access is missing.

Do Agentforce Apex actions run under governor limits?

Yes — an agent action is ordinary Apex and runs under the same synchronous governor limits (roughly 100 SOQL queries, 150 DML statements, 10 seconds CPU, 6 MB heap per transaction). It’s sharper for agents because the reasoning engine may call an action multiple times within a single user turn as it plans and re-evaluates, so an action that’s fine called once can exhaust limits when it’s called repeatedly. Query and return only the data you need — both to stay within limits and to keep the returned data small enough not to dilute the model’s context — and return errors as a structured field rather than throwing.


Extending an agent with custom logic and hitting actions that won’t fire, misread their inputs, or blow governor limits mid-conversation? Talk to us — building governed Agentforce actions that take real, scoped, well-described actions is exactly the work we do.

Keep reading

All insights