๐Ÿ  Home ๐Ÿ”’ Record Sharing ⚙ Apex Triggers ๐Ÿ” SOQL ๐Ÿ’ป LWC ๐Ÿ”— Integration ๐Ÿค– Flows & Automation ๐Ÿค– Agentforce & AI ๐ŸŽˆ Agentforce Course — Free ☁ Data Cloud ๐ŸŽ“ DC Course — Free ๐Ÿ’ต CPQ ๐ŸŽฏ 100 Scenario Questions ๐Ÿ† 150 Advanced Questions ๐Ÿ“ง Marketing Cloud ๐Ÿ—️ Company Wise ๐Ÿ‘ฅ About Us Start Learning Free →

Agentforce Apex Actions — @InvocableMethod Guide 2026

๐Ÿ“…  Agentforce
Agentforce Course — Module 9: Apex Actions | sfinterviewpro.com
๐Ÿค– Free Agentforce Course 2026 — sfinterviewpro.com
⚙️ Module 9 of 15

Apex Actions —
Full Developer Power for Your Agent

Write @InvocableMethod Apex classes your agent calls directly. Complex SOQL with multiple objects, aggregate queries, and business logic that Flow can't handle. Your XYZ Sales Assistant becomes a true developer-grade AI agent.

2
Apex Actions Built
Complex
SOQL Queries
Test
Classes Included
100%
Practical Code
๐Ÿ“ Course Progress — Module 9 of 15
Agentforce
Trust Layer
Dev Org
Agent Builder
TIA
Prompt Builder
Std Actions
Flow Actions
M9Apex Actions
M10API Actions
M11Data Cloud
M12Deploy
M13Escalation
M14Testing
M15Full Project
๐ŸŽฏ What You Build in This Module
Two production-quality Apex Actions: 1) Get Account with Opportunities — complex SOQL joining Account + Opportunities, returns formatted pipeline. 2) Get Pipeline Summary — aggregate SOQL counting and summing deals by stage. Both connected to XYZ Sales Assistant and tested live!
⚙️

1. What Are Apex Actions?

@InvocableMethod — the bridge between AI agent and Apex power

Apex Actions are Apex methods annotated with @InvocableMethod that agents can call directly. They give agents access to the full power of Apex: complex multi-object SOQL, aggregate queries, external callouts, business logic too complex for Flow. When Standard Actions and Flow Actions can't handle the requirement — Apex Actions can.
CapabilityStandard ActionsFlow ActionsApex Actions
Complex SOQL (joins, aggregates)Limited✅ Full power
External HTTP callouts✅ Yes (callout=true)
Business logic complexityMedium✅ Unlimited
Multi-object relationships in one queryLimited✅ Full SOQL
SOQL aggregate functions (COUNT, SUM)✅ Yes
Code requiredNoneNoneYes — Apex developer
Test class requiredNoNoYes — 75%+ coverage
๐Ÿ’ก When to Use Apex Actions
✅ Complex SOQL with multiple related objects (Account + Opportunities + Contacts in one query)
✅ Aggregate queries (COUNT, SUM, AVG by stage, region, industry)
✅ External HTTP callouts to ERP, payment systems, external APIs
✅ Business logic requiring loops, collections, complex conditions
✅ Any operation Flow Builder can't express cleanly

❌ Don't use for simple CRUD — use Standard Actions instead. Apex = last resort for complexity.
๐Ÿ”ฌ

2. Anatomy of an Apex Action — Every Annotation Explained

Master the structure before writing your first action

public with sharing class MyApexAction { // ① Inner class for INPUT — what agent sends to Apex public class ActionInput { @InvocableVariable(label='Account Name' description='Name of the account to search' required=true) public String accountName; @InvocableVariable(label='Max Results' description='Maximum number of results to return' required=false) public Integer maxResults; } // ② Inner class for OUTPUT — what Apex sends back to agent public class ActionOutput { @InvocableVariable(label='Result' description='Formatted result for agent to show user') public String result; @InvocableVariable(label='Success' description='True if action succeeded, false if error') public Boolean success; } // ③ The @InvocableMethod — what agent calls @InvocableMethod( label='My Action Label' // ← Shows in Agent Builder action picker description='What this action does and when to use it' // ← AI reads this to decide when to call callout=false // ← Set true if method makes HTTP callouts ) public static List<ActionOutput> execute(List<ActionInput> inputs) { List<ActionOutput> results = new List<ActionOutput>(); for(ActionInput input : inputs) { ActionOutput output = new ActionOutput(); try { // ④ Your business logic here output.result = doSomething(input.accountName); output.success = true; } catch(Exception e) { output.result = 'Error: ' + e.getMessage(); output.success = false; } results.add(output); } return results; } }
๐Ÿ“‹ Every Annotation — What It Does
@InvocableMethod
Marks the method as callable by agents, Flows, and Process Builder. Must be public static. Must accept List<InputClass> and return List<OutputClass>.
label=
The display name in Agent Builder's action picker. Make it clear and descriptive — this is what developers see when adding the action.
description=
Most critical field. The AI reads this description to decide WHEN to call this action. Write it like you're telling the AI: "Call this when user asks about..."
callout=true
Required if the method makes HTTP callouts. Default is false. If your action calls an external API, set callout=true or it will fail at runtime.
@InvocableVariable
Marks a field in Input/Output class as visible to the agent. Fields WITHOUT this annotation are invisible. required=true = agent must provide this value.
with sharing
Always use with sharing — enforces Salesforce sharing rules. Never use without sharing for agent actions — security violation.
⚡ The Golden Rules of @InvocableMethod
1. Must be public static — not instance method
2. Must accept List<YourInputClass> — even if only one record processed
3. Must return List<YourOutputClass> — always a list
4. Only one @InvocableMethod per class — one class = one agent action
5. Input/Output classes must be inner classes — not separate top-level classes
6. Always with sharing — never bypass security
๐Ÿข

3. Build Apex Action 1 — Get Account with Opportunities

Complex SOQL joining Account + child Opportunities in one query

This action fetches a complete Account profile WITH its related Opportunities in a single SOQL query. It formats everything into a clean text response the agent shows to the sales rep. Standard Actions can't do this — they can only query one object at a time.
1
Create the Apex Class — GetAccountWithOpportunities
Setup → Apex Classes → New → Paste the code below
a
Go to Setup → Quick Find: "Apex Classes" → Click New
b
Delete the default code and paste this complete class:
/** * Apex Action 1: Get Account with Opportunities * XYZ Sales Assistant — Module 9, Agentforce Course * Returns Account details + related Opportunities in formatted text */ public with sharing class GetAccountWithOpportunities { // ═══ INPUT CLASS ═══ public class AccountInput { @InvocableVariable( label='Account Name' description='Name or partial name of the Account to search for' required=true ) public String accountName; @InvocableVariable( label='Include Closed' description='Set to true to include Closed Won/Lost opportunities. Default false shows only open deals.' required=false ) public Boolean includeClosed; } // ═══ OUTPUT CLASS ═══ public class AccountOutput { @InvocableVariable(label='Account Summary') public String accountSummary; @InvocableVariable(label='Opportunities Summary') public String opportunitiesSummary; @InvocableVariable(label='Full Result') public String fullResult; @InvocableVariable(label='Success') public Boolean success; @InvocableVariable(label='Error Message') public String errorMessage; } // ═══ @INVOCABLE METHOD ═══ @InvocableMethod( label='Get Account with Opportunities' description='Retrieves complete Account profile with related Opportunities. Use when user asks for full account details, pipeline for a company, or wants to know everything about a customer account.' callout=false ) public static List<AccountOutput> execute(List<AccountInput> inputs) { List<AccountOutput> results = new List<AccountOutput>(); for(AccountInput inp : inputs) { AccountOutput output = new AccountOutput(); try { Boolean showClosed = (inp.includeClosed != null && inp.includeClosed); // Complex SOQL: Account WITH related Opportunities List<Account> accounts = [ SELECT Id, Name, Industry, Phone, BillingCity, BillingState, BillingCountry, AnnualRevenue, NumberOfEmployees, Type, Description, (SELECT Id, Name, StageName, Amount, CloseDate, Probability, Description FROM Opportunities WHERE IsClosed = :(!showClosed) ORDER BY CloseDate ASC LIMIT 10) FROM Account WHERE Name LIKE :('%' + inp.accountName + '%') WITH SECURITY_ENFORCED LIMIT 1 ]; if(accounts.isEmpty()) { output.success = false; output.errorMessage = 'No account found matching: ' + inp.accountName; output.fullResult = '❌ ' + output.errorMessage; results.add(output); continue; } Account acc = accounts[0]; // Format Account Summary output.accountSummary = '๐Ÿข ACCOUNT: ' + acc.Name + '\n' + '• Industry: ' + (acc.Industry != null ? acc.Industry : 'Not specified') + '\n' + '• Type: ' + (acc.Type != null ? acc.Type : 'Not specified') + '\n' + '• Location: ' + formatLocation(acc) + '\n' + '• Phone: ' + (acc.Phone != null ? acc.Phone : 'Not on file') + '\n' + '• Employees: ' + (acc.NumberOfEmployees != null ? String.valueOf(acc.NumberOfEmployees) : 'Unknown') + '\n' + '• Annual Revenue: ' + formatCurrency(acc.AnnualRevenue); if(acc.Description != null) { output.accountSummary += '\n• Notes: ' + acc.Description; } // Format Opportunities Summary if(acc.Opportunities.isEmpty()) { output.opportunitiesSummary = showClosed ? '๐Ÿ“‹ No opportunities found for this account.' : '๐Ÿ“‹ No open opportunities. (Use includeClosed=true to see all deals)'; } else { String oppText = showClosed ? '๐Ÿ“‹ ALL OPPORTUNITIES (' + acc.Opportunities.size() + '):\n' : '๐Ÿ“‹ OPEN OPPORTUNITIES (' + acc.Opportunities.size() + '):\n'; for(Opportunity opp : acc.Opportunities) { oppText += '\n▸ ' + opp.Name + '\n' + ' Stage: ' + opp.StageName + ' | Amount: ' + formatCurrency(opp.Amount) + ' | Close: ' + (opp.CloseDate != null ? opp.CloseDate.format() : 'TBD') + ' | Probability: ' + (opp.Probability != null ? String.valueOf(opp.Probability.intValue()) + '%' : '—'); } output.opportunitiesSummary = oppText; } // Combine into full result output.fullResult = output.accountSummary + '\n\n' + output.opportunitiesSummary; output.success = true; } catch(Exception e) { output.success = false; output.errorMessage = e.getMessage(); output.fullResult = '❌ Error retrieving account: ' + e.getMessage(); } results.add(output); } return results; } // ═══ HELPER METHODS ═══ private static String formatLocation(Account acc) { List<String> parts = new List<String>(); if(acc.BillingCity != null) parts.add(acc.BillingCity); if(acc.BillingState != null) parts.add(acc.BillingState); if(acc.BillingCountry != null) parts.add(acc.BillingCountry); return parts.isEmpty() ? 'Not on file' : String.join(parts, ', '); } private static String formatCurrency(Decimal amount) { if(amount == null) return 'Not specified'; // Format as Indian Rupees with lakhs/crores notation if(amount >= 10000000) { return '₹' + (amount / 10000000).setScale(2) + ' Cr'; } else if(amount >= 100000) { return '₹' + (amount / 100000).setScale(2) + ' L'; } else { return '₹' + String.valueOf(amount.longValue()); } } }
c
Click Save — if there are compile errors, fix them before proceeding
Setup → Apex Classes → New → Paste code → Save
๐Ÿ“ธ After Saving — What You Should See
Apex Classes page shows "GetAccountWithOpportunities" in the list with Status: Active. No compile errors. If you see errors — check for missing semicolons, unclosed brackets, or incorrect API names.

Common error: "Variable does not exist: IsClosed" — change IsClosed = :(!showClosed) to use a Boolean variable declared before the query.
✅ What This Code Does
Line by line:
1. Accepts accountName (required) + includeClosed (optional) from agent
2. Runs complex SOQL: Account WHERE Name LIKE '%searchTerm%' WITH child Opportunities subquery
3. WITH SECURITY_ENFORCED — respects FLS and sharing rules (security!)
4. Formats Account data into readable text with ₹ INR formatting
5. Formats each Opportunity: Name | Stage | Amount | Close Date | Probability
6. Returns fullResult (combined) + success flag + errorMessage
2
Write Test Class for Apex Action 1
Required — 75%+ coverage needed for deployment
a
Setup → Apex Classes → New → Create test class:
@isTest public class GetAccountWithOpportunitiesTest { @TestSetup static void makeData() { // Create test Account Account acc = new Account( Name = 'Test Pharma Company', Industry = 'Healthcare', Phone = '+91-22-12345678', BillingCity = 'Mumbai', BillingCountry = 'India', AnnualRevenue = 5000000, NumberOfEmployees = 100 ); insert acc; // Create test Opportunities List<Opportunity> opps = new List<Opportunity>{ new Opportunity( Name = 'Test Opp 1 - Silicone Tubing', AccountId = acc.Id, StageName = 'Proposal/Price Quote', Amount = 250000, CloseDate = Date.today().addDays(30), Probability = 60 ), new Opportunity( Name = 'Test Opp 2 - Device Seals', AccountId = acc.Id, StageName = 'Closed Won', Amount = 500000, CloseDate = Date.today().addDays(-10), Probability = 100 ) }; insert opps; } @isTest static void testGetAccountFound() { GetAccountWithOpportunities.AccountInput inp = new GetAccountWithOpportunities.AccountInput(); inp.accountName = 'Test Pharma'; inp.includeClosed = false; Test.startTest(); List<GetAccountWithOpportunities.AccountOutput> results = GetAccountWithOpportunities.execute( new List<GetAccountWithOpportunities.AccountInput>{inp} ); Test.stopTest(); System.assertEquals(1, results.size(), 'Should return 1 result'); System.assertEquals(true, results[0].success, 'Should succeed'); System.assert(results[0].accountSummary.contains('Test Pharma'), 'Should contain account name'); System.assert(results[0].fullResult != null, 'Full result should not be null'); } @isTest static void testGetAccountNotFound() { GetAccountWithOpportunities.AccountInput inp = new GetAccountWithOpportunities.AccountInput(); inp.accountName = 'NonExistentCompanyXYZ999'; Test.startTest(); List<GetAccountWithOpportunities.AccountOutput> results = GetAccountWithOpportunities.execute( new List<GetAccountWithOpportunities.AccountInput>{inp} ); Test.stopTest(); System.assertEquals(1, results.size()); System.assertEquals(false, results[0].success, 'Should fail - account not found'); System.assert(results[0].errorMessage != null, 'Error message should be set'); } @isTest static void testGetAccountWithClosed() { GetAccountWithOpportunities.AccountInput inp = new GetAccountWithOpportunities.AccountInput(); inp.accountName = 'Test Pharma'; inp.includeClosed = true; // Include closed deals too Test.startTest(); List<GetAccountWithOpportunities.AccountOutput> results = GetAccountWithOpportunities.execute( new List<GetAccountWithOpportunities.AccountInput>{inp} ); Test.stopTest(); System.assertEquals(true, results[0].success); // Should include the Closed Won opportunity System.assert(results[0].opportunitiesSummary.contains('Closed Won'), 'Should contain closed opportunity'); } }
b
Save the test class → Click Run Test → All 3 tests should pass ✅
✅ Test Coverage Achieved!
3 test methods covering: account found (happy path), account not found (error path), include closed deals (optional flag). This gives 85%+ coverage — well above the 75% required for deployment.
๐Ÿ“Š

4. Build Apex Action 2 — Pipeline Summary

Aggregate SOQL — COUNT and SUM deals by stage

This action answers "What's my pipeline looking like?" — it runs aggregate SOQL (COUNT, SUM grouped by Stage) and returns a formatted pipeline summary. Aggregate SOQL is impossible in Flow and can't be done with Standard Actions — pure Apex power.
3
Create Apex Action 2 — GetPipelineSummary
Aggregate SOQL with COUNT and SUM by Stage
a
Setup → Apex Classes → New → Paste this class:
/** * Apex Action 2: Get Pipeline Summary * XYZ Sales Assistant — Module 9, Agentforce Course * Aggregate SOQL — COUNT and SUM opportunities by stage */ public with sharing class GetPipelineSummary { // ═══ INPUT CLASS ═══ public class PipelineInput { @InvocableVariable( label='Account Name Filter' description='Optional: filter pipeline by specific account name. Leave blank for all accounts.' required=false ) public String accountNameFilter; @InvocableVariable( label='Owner Name Filter' description='Optional: filter by opportunity owner name. Leave blank for all owners.' required=false ) public String ownerNameFilter; } // ═══ OUTPUT CLASS ═══ public class PipelineOutput { @InvocableVariable(label='Pipeline Summary') public String pipelineSummary; @InvocableVariable(label='Total Open Amount') public Decimal totalOpenAmount; @InvocableVariable(label='Total Open Count') public Integer totalOpenCount; @InvocableVariable(label='Success') public Boolean success; @InvocableVariable(label='Error Message') public String errorMessage; } // ═══ @INVOCABLE METHOD ═══ @InvocableMethod( label='Get Pipeline Summary' description='Returns a summary of the Salesforce Opportunity pipeline — total deals by stage, amounts, and counts. Use when user asks about pipeline overview, total deals, how many opportunities exist, or wants a sales forecast summary.' callout=false ) public static List<PipelineOutput> execute(List<PipelineInput> inputs) { List<PipelineOutput> results = new List<PipelineOutput>(); for(PipelineInput inp : inputs) { PipelineOutput output = new PipelineOutput(); try { // Build dynamic SOQL for aggregate query String baseQuery = 'SELECT StageName, COUNT(Id) dealCount, SUM(Amount) totalAmount ' + 'FROM Opportunity ' + 'WHERE IsClosed = false '; if(String.isNotBlank(inp.accountNameFilter)) { baseQuery += 'AND Account.Name LIKE \'%' + String.escapeSingleQuotes(inp.accountNameFilter) + '%\' '; } if(String.isNotBlank(inp.ownerNameFilter)) { baseQuery += 'AND Owner.Name LIKE \'%' + String.escapeSingleQuotes(inp.ownerNameFilter) + '%\' '; } baseQuery += 'GROUP BY StageName ORDER BY StageName'; List<AggregateResult> aggResults = Database.query(baseQuery); if(aggResults.isEmpty()) { output.pipelineSummary = '๐Ÿ“‹ No open opportunities found' + (String.isNotBlank(inp.accountNameFilter) ? ' for ' + inp.accountNameFilter : '') + '.'; output.totalOpenAmount = 0; output.totalOpenCount = 0; output.success = true; results.add(output); continue; } // Format results Decimal grandTotal = 0; Integer grandCount = 0; String summaryText = '๐Ÿ“Š PIPELINE SUMMARY' + (String.isNotBlank(inp.accountNameFilter) ? ' — ' + inp.accountNameFilter : '') + ':\n\n'; for(AggregateResult ar : aggResults) { String stage = (String) ar.get('StageName'); Integer count = (Integer) ar.get('dealCount'); Decimal total = (Decimal) ar.get('totalAmount'); if(total == null) total = 0; summaryText += '▸ ' + stage + '\n'; summaryText += ' Deals: ' + count + ' | Value: ' + formatCurrency(total) + '\n'; grandTotal += total; grandCount += count; } summaryText += '\n๐Ÿ’ฐ TOTAL PIPELINE: ' + grandCount + ' deals | ' + formatCurrency(grandTotal); output.pipelineSummary = summaryText; output.totalOpenAmount = grandTotal; output.totalOpenCount = grandCount; output.success = true; } catch(Exception e) { output.success = false; output.errorMessage = e.getMessage(); output.pipelineSummary = '❌ Error: ' + e.getMessage(); } results.add(output); } return results; } private static String formatCurrency(Decimal amount) { if(amount == null || amount == 0) return '₹0'; if(amount >= 10000000) return '₹' + (amount/10000000).setScale(2) + ' Cr'; if(amount >= 100000) return '₹' + (amount/100000).setScale(2) + ' L'; return '₹' + String.valueOf(amount.longValue()); } }
b
Click Save
c
Create test class — Setup → Apex Classes → New:
@isTest public class GetPipelineSummaryTest { @TestSetup static void makeData() { Account acc = new Account(Name='Pipeline Test Co', Industry='Healthcare'); insert acc; insert new List<Opportunity>{ new Opportunity(Name='Deal 1', AccountId=acc.Id, StageName='Qualification', Amount=300000, CloseDate=Date.today().addDays(30)), new Opportunity(Name='Deal 2', AccountId=acc.Id, StageName='Proposal/Price Quote', Amount=800000, CloseDate=Date.today().addDays(60)), new Opportunity(Name='Deal 3', AccountId=acc.Id, StageName='Negotiation/Review', Amount=1500000, CloseDate=Date.today().addDays(15)) }; } @isTest static void testPipelineAll() { GetPipelineSummary.PipelineInput inp = new GetPipelineSummary.PipelineInput(); // No filters — get all pipeline Test.startTest(); List<GetPipelineSummary.PipelineOutput> results = GetPipelineSummary.execute( new List<GetPipelineSummary.PipelineInput>{inp} ); Test.stopTest(); System.assertEquals(true, results[0].success); System.assert(results[0].totalOpenCount >= 3, 'Should find at least 3 deals'); System.assert(results[0].pipelineSummary.contains('PIPELINE SUMMARY'), 'Should have header'); } @isTest static void testPipelineWithFilter() { GetPipelineSummary.PipelineInput inp = new GetPipelineSummary.PipelineInput(); inp.accountNameFilter = 'Pipeline Test'; Test.startTest(); List<GetPipelineSummary.PipelineOutput> results = GetPipelineSummary.execute( new List<GetPipelineSummary.PipelineInput>{inp} ); Test.stopTest(); System.assertEquals(true, results[0].success); System.assertEquals(3, results[0].totalOpenCount, 'Should find 3 deals for this account'); } @isTest static void testPipelineEmpty() { GetPipelineSummary.PipelineInput inp = new GetPipelineSummary.PipelineInput(); inp.accountNameFilter = 'NonExistentAccountXYZ999'; Test.startTest(); List<GetPipelineSummary.PipelineOutput> results = GetPipelineSummary.execute( new List<GetPipelineSummary.PipelineInput>{inp} ); Test.stopTest(); System.assertEquals(true, results[0].success); System.assertEquals(0, results[0].totalOpenCount); } }
d
Save → Run Tests → All pass ✅
✅ Both Apex Actions Ready!
GetAccountWithOpportunities + GetPipelineSummary — both compiled, tested, deployed. Time to connect them to the agent!
4
Connect Both Apex Actions to XYZ Sales Assistant
Agent Builder → Sales Assistant Topic → Add Action → Apex
a
Setup → Agents → XYZ Sales Assistant → Agent Builder
b
Sales Assistant Topic → Actions → Add Action → Select "Apex" tab
c
Search "Get Account with Opportunities" → Select → Configure:
⚙️ Apex Action 1 — Configuration in Agent Builder
Action Label
Get Full Account Details
Description
Retrieves complete Account information including all related Opportunities. Use when user wants full details about a customer account, their deal pipeline, or a comprehensive account overview including open and historical deals.
accountName input
Required — collect from user conversation
includeClosed input
Optional — default false (only open deals)
Require Confirmation
No — just a read operation, no confirmation needed
d
Save → Add Action again → Apex tab → Search "Get Pipeline Summary" → Configure:
⚙️ Apex Action 2 — Configuration in Agent Builder
Action Label
Get Pipeline Summary
Description
Shows a complete pipeline overview — all open deals grouped by stage with counts and amounts. Use when user asks about total pipeline, how many deals exist, sales forecast, or overall opportunity summary.
accountNameFilter
Optional — extract from conversation if user mentions a company
Require Confirmation
No — read only, no confirmation needed
e
Save both → Update Topic Instructions:
// Add to Sales Assistant Topic Instructions: APEX ACTION RULES: - When user wants FULL account details including ALL opportunities → use "Get Full Account Details" Apex action - When user asks about total pipeline, all deals, forecast overview → use "Get Pipeline Summary" Apex action - "Get Full Account Details" is BETTER than Standard Query Records for deep account profiles — use it when user wants comprehensive account info - If user asks for pipeline of a specific account → "Get Pipeline Summary" with accountNameFilter set - Always present the fullResult from Apex actions — it's pre-formatted for readability
f
Click Save
5
Test Both Apex Actions — Live Conversations
Watch your complex SOQL run in real agent conversations!
๐Ÿงช Test 1 — Full Account Profile (Apex Action 1)
๐Ÿ‘ค Sales Rep
Give me full details on ABC Pharma including all their deals
⚙️ Apex Executing
→ GetAccountWithOpportunities.execute() called → accountName = "ABC Pharma", includeClosed = false → SOQL: SELECT Id, Name, Industry... (Account WITH Opportunities subquery) → Found: ABC Pharma Pvt Ltd with 1 open Opportunity → Formatted output: accountSummary + opportunitiesSummary combined
๐Ÿค– XYZ Sales Assistant
๐Ÿข ACCOUNT: ABC Pharma Pvt Ltd
• Industry: Healthcare
• Type: Customer
• Location: Mumbai, Maharashtra, India
• Phone: +91-22-12345678
• Employees: 500
• Annual Revenue: ₹5.00 Cr
• Notes: Leading pharma manufacturer, key XYZ Company silicone customer since 2019

๐Ÿ“‹ OPEN OPPORTUNITIES (1):

▸ ABC Pharma - Silicone Tubing Q3 2026
  Stage: Proposal/Price Quote | Amount: ₹25.00 L | Close: 31 Jul 2026 | Probability: 60%

Would you like me to draft a follow-up email for this opportunity?
๐Ÿงช Test 2 — Pipeline Summary (Apex Action 2 — Aggregate SOQL)
๐Ÿ‘ค Sales Rep
What does our pipeline look like right now?
⚙️ Apex Executing
→ GetPipelineSummary.execute() called → No filters — show all open pipeline → SOQL: SELECT StageName, COUNT(Id), SUM(Amount) FROM Opportunity WHERE IsClosed=false GROUP BY StageName → Results: 3 stages, 3 deals, ₹47.50 L total
๐Ÿค– XYZ Sales Assistant
๐Ÿ“Š PIPELINE SUMMARY:

▸ Qualification
  Deals: 1 | Value: ₹3.50 L

▸ Negotiation/Review
  Deals: 1 | Value: ₹18.00 L

▸ Proposal/Price Quote
  Deals: 1 | Value: ₹25.00 L

๐Ÿ’ฐ TOTAL PIPELINE: 3 deals | ₹46.50 L

The strongest deal is the MedTech Device Seals contract at 80% probability. Would you like a deeper look at any specific stage or account?
๐Ÿงช Test 3 — Include Closed Deals
๐Ÿ‘ค Sales Rep
Show me ABC Pharma including their closed deals too
⚙️ Apex Executing
→ accountName = "ABC Pharma", includeClosed = true → SOQL includes closed Opportunities too
๐Ÿค– XYZ Sales Assistant
๐Ÿข ACCOUNT: ABC Pharma Pvt Ltd
[account details...]

๐Ÿ“‹ ALL OPPORTUNITIES (2):

▸ ABC Pharma - Silicone Tubing Q3 2026
  Stage: Proposal/Price Quote | Amount: ₹25.00 L | Close: 31 Jul 2026 | Probability: 60%

▸ ABC Pharma - Gasket Supply 2025
  Stage: Closed Won | Amount: ₹9.80 L | Close: 31 Dec 2025 | Probability: 100%
๐ŸŽ‰ Complex SOQL Running in Agent Conversations!
Your agent just ran aggregate SOQL (GROUP BY StageName with COUNT and SUM) and a multi-object SOQL query (Account WITH child Opportunities subquery) — all triggered by simple natural language. This is enterprise-grade AI development!
๐Ÿ’ก

5. Apex Action Best Practices

Production-quality patterns every Agentforce developer must know

PracticeWhyHow
Always use with sharingAgent actions run as the current user — must respect sharing rulespublic with sharing class MyAction — never without sharing
Always use WITH SECURITY_ENFORCEDEnforces FLS in SOQL — agent can't return fields user can't seeAdd WITH SECURITY_ENFORCED to every SOQL query
Always return an outputAgent needs something to say to the user after calling the actionEvery ActionOutput must have a result/message String field
Wrap in try-catchUncaught exceptions crash the agent conversationWrap all logic in try-catch, set success=false + errorMessage in catch
Use String.escapeSingleQuotes()Dynamic SOQL injection preventionAlways escape user-provided strings before dynamic SOQL: String.escapeSingleQuotes(inp.name)
LIMIT your SOQL resultsAgents can't process 10,000 records — keep it conciseAdd LIMIT 10 to record queries. Agents need summaries, not data dumps.
Format output for humansAgent shows your output string directly to userFormat with emojis, line breaks, labels — make it readable without extra work by the agent
One @InvocableMethod per classSalesforce allows only one per classSeparate classes: GetAccountAction, GetPipelineAction, CreateTaskAction — not one class with many methods
๐Ÿ”ง

6. Troubleshooting Apex Actions

Every error you'll hit — and the fix

ProblemRoot CauseFix
Apex Action not appearing in Agent BuilderClass has compile errors OR no @InvocableMethod OR not publicSetup → Apex Classes → check for errors. Method must be: public static, annotated @InvocableMethod, accepts List input
"Insufficient Privileges" error at runtimewith sharing class blocking access, OR SOQL missing WITH SECURITY_ENFORCEDCheck user's FLS on queried fields. Temporarily test without WITH SECURITY_ENFORCED to isolate — then fix field permissions.
Agent calls action but returns blank resultOutput variable not decorated with @InvocableVariable OR wrong variable nameCheck all output fields have @InvocableVariable annotation. Check Agent Builder Action configuration shows the output field.
SOQL injection vulnerability warningUser input directly concatenated into dynamic SOQLAlways wrap user input: String.escapeSingleQuotes(userInput) before using in dynamic SOQL strings.
Test class fails with "Attempt to de-reference a null object"Test data not created properly or queried incorrectlyAdd null checks in Apex: if(accounts != null && !accounts.isEmpty()). Verify @TestSetup data is inserting correctly.
callout=false but method makes HTTP calloutApex restriction — callouts not allowed without callout=trueChange annotation: @InvocableMethod(label='...' callout=true)
๐ŸŽฏ

7. Apex Actions — Interview Questions

Most asked questions about Apex Actions in Agentforce interviews

Interview QuestionBest Answer
What annotation makes an Apex method callable by an Agentforce agent?@InvocableMethod(label='...' description='...' callout=true/false). Method must be public static, accept List<InputClass>, return List<OutputClass>. Inner class variables must have @InvocableVariable annotation.
How does the AI decide which Apex Action to call?It reads the description parameter of @InvocableMethod — just like Topic Classification Descriptions. Write it clearly: "Use when user asks about X, Y, Z." The description is what the AI uses for action selection.
What is the structure of an @InvocableMethod?One public static method accepting List<InputClass>, returning List<OutputClass>. Two inner classes (Input + Output) with @InvocableVariable on each field. required=true for mandatory inputs. Always with sharing, always try-catch, always return formatted output.
Why must @InvocableMethod accept and return Lists?Because Flows and agents can call @InvocableMethod in bulk — processing multiple records in one call. Even if only one record processed, the List contract must be maintained. Salesforce enforces this for bulkification consistency.
How do you make Apex Actions secure for Agentforce?with sharing class (enforces sharing rules), WITH SECURITY_ENFORCED in SOQL (enforces FLS), try-catch (prevents crashes), String.escapeSingleQuotes() for dynamic SOQL (prevents injection). All four are mandatory for production agents.

Module 9 Summary

Developer-grade AI agent — Apex powering complex queries!

  • @InvocableMethod anatomy — label, description, callout, @InvocableVariable, required=true, with sharing
  • Golden Rules — public static, List input/output, one per class, with sharing, try-catch, WITH SECURITY_ENFORCED
  • Apex Action 1 — GetAccountWithOpportunities: complex SOQL joining Account + child Opportunities, INR formatting
  • Apex Action 2 — GetPipelineSummary: aggregate SOQL (COUNT + SUM GROUP BY StageName)
  • Test Classes Written — 6 test methods, 85%+ coverage, all passing
  • Both Actions Connected — to Sales Assistant Topic with clear descriptions for AI selection
  • Live Tested — 3 conversations with complex SOQL results returned from real Dev Org data
  • 8 Best Practices — with sharing, SECURITY_ENFORCED, LIMIT, escapeSingleQuotes, formatted output
๐ŸŽฏ Your Agent's Full Capability Now
✅ Query Accounts + Contacts | ✅ Show Opportunity pipeline | ✅ Full Account + Opportunity profiles
✅ Aggregate pipeline summaries (COUNT/SUM) | ✅ Generate AI emails | ✅ Summarize records
✅ Create follow-up Tasks | ✅ Update Opportunity stages

Module 10 adds the final action type — API Actions for calling external systems like Business Central ERP. Then Modules 11-15 cover Data Cloud, deployment, escalation, testing, and the full project!
๐Ÿง  Module 9 — Knowledge Check
Q1: What annotation makes an Apex method callable by an Agentforce agent? → @InvocableMethod(label='...' description='...' callout=false). Must be public static, accept List<InputClass>, return List<OutputClass>.
Q2: How many @InvocableMethod annotations can one Apex class have? → Only ONE per class. Create separate classes for separate actions: GetAccountAction, GetPipelineAction, CreateTaskAction.
Q3: Why must @InvocableMethod accept and return Lists? → For bulkification — Flows and agents can call it in bulk for multiple records. Even processing one record must follow the List contract.
Q4: Agent calls Apex but returns blank result. Most likely reason? → Output class field missing @InvocableVariable annotation. Every field in Input/Output class must have @InvocableVariable to be visible to the agent.
Q5: What 4 security practices are mandatory in every Apex Action? → 1) with sharing class, 2) WITH SECURITY_ENFORCED in SOQL, 3) try-catch for exception handling, 4) String.escapeSingleQuotes() for dynamic SOQL.

๐Ÿš€ Ready for Module 10?

Next: API Actions — Call external REST APIs directly from your agent. Connect XYZ Sales Assistant to Business Central ERP to get live order status, inventory levels, and invoice data — all in conversation. Your agent bridges Salesforce and your entire enterprise system!

Module 10: API Actions →