Technical Deep-Dive March 5, 2026 16 min read

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.

Tyler Colby · Founder, Colby's Data Movers

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.