The Agentforce demos almost always show the happy path: an agent calls an action, the action calls an API, data comes back clean, the agent responds helpfully. The integration layer looks trivial.
Then you point the agent at your actual ERP — a SAP instance that speaks SOAP, a mainframe that requires a middleware hop, a homegrown Oracle system where "success" is a 200 with <STATUS>ERROR</STATUS> in the XML body — and the demo architecture stops working.
This post covers the integration patterns that hold up in production. It assumes you've read Parts 1 and 2 of this series and understand the multi-agent execution model and the AWU governance context.
Choosing the Right Callout Mechanism
Salesforce gives you three paths for agent-initiated callouts: External Services, Named Credentials with a custom Apex action, and raw callouts inside Apex actions. The docs present External Services as the preferred approach. The docs are not wrong, but they're not complete either.
External Services works well when the target system has a well-formed OpenAPI spec and your integration is essentially read-only or write-simple (one endpoint, predictable payload, structured response). The auto-generated invocable action reduces boilerplate and the schema is surfaced natively in the agent builder. Where it breaks down: error handling is thin. If the remote system returns a non-2xx status or a malformed body, External Services gives you a generic fault with limited diagnostic context. For a system that returns 200 with an error flag in the payload — very common in older SOAP-over-REST wrappers — the auto-generated action will treat the call as successful and pass the error body to the agent as if it were valid data. The agent will then attempt to act on it.
Named Credentials with a custom Apex callout action is the right choice for the majority of enterprise integrations. You get full control over the HTTP lifecycle: request construction, response parsing, error classification, retry logic, and timeout handling. The Named Credential handles the auth layer cleanly (OAuth 2.0, Basic, JWT, cert-based) without credentials in code. The operational cost is writing and maintaining the Apex, but for anything touching a production ERP, that control is worth it.
Raw Apex callouts without Named Credentials are rarely the right answer for new implementations. The only legitimate case is a legacy integration where the credential management is already handled externally and you're wrapping an existing callout pattern. If you're starting from scratch, use Named Credentials — it removes an entire class of security and rotation risk.
One hard constraint that shapes all three choices: Salesforce allows a maximum of 10 callouts per synchronous transaction. An agent that chains multiple actions in a single execution context can hit this limit faster than you'd expect if each action makes more than one callout. Design actions to be callout-efficient — one callout per action where possible, batching where the target API supports it.
Failure Modes When There Is No User
When a UI callout fails, you show the user an error. They click retry. The problem surfaces immediately and has a human to resolve it.
When an agent-initiated callout fails, there is no user. The failure mode depends entirely on how you've designed the action to behave when things go wrong.
The naive pattern is to throw an exception and let the agent handle it. Agents can catch tool errors and attempt recovery, but they're reasoning about a failure they don't fully understand — they know the action failed, not why it failed or whether retrying is safe. An agent that retries a failed write action three times may create three duplicate records if the first write actually succeeded but the response was lost in transit. Each retry consumed AWUs. You now have data inconsistency and a larger bill.
A more reliable pattern: classify failures before returning them to the agent.
public class ErpCalloutAction {
@InvocableMethod(label='Read ERP Order' description='Fetches order data from ERP by order number')
public static List<Result> execute(List<Request> requests) {
List<Result> results = new List<Result>();
for (Request req : requests) {
Result r = new Result();
try {
HttpRequest httpReq = buildRequest(req.orderNumber);
HttpResponse res = new Http().send(httpReq);
r = parseResponse(res, req.orderNumber);
} catch (CalloutException e) {
// Network-level failure — safe to retry
r.success = false;
r.errorCode = 'CALLOUT_TIMEOUT';
r.errorMessage = 'ERP unreachable. Retry is safe.';
r.retryable = true;
} catch (Exception e) {
// Unknown failure — do not retry automatically
r.success = false;
r.errorCode = 'UNEXPECTED_ERROR';
r.errorMessage = e.getMessage();
r.retryable = false;
}
results.add(r);
}
return results;
}
private static Result parseResponse(HttpResponse res, String orderNumber) {
Result r = new Result();
if (res.getStatusCode() == 200) {
// Check for application-level error in body — common in SOAP/legacy APIs
String body = res.getBody();
if (body.contains('<STATUS>ERROR</STATUS>') || body.contains('"error":true')) {
r.success = false;
r.errorCode = 'APPLICATION_ERROR';
r.errorMessage = 'ERP returned a business error for order ' + orderNumber;
r.retryable = false; // application errors don't resolve by retrying
return r;
}
// Parse actual data
r.success = true;
r.orderData = body;
} else if (res.getStatusCode() == 503 || res.getStatusCode() == 429) {
r.success = false;
r.errorCode = 'SERVICE_UNAVAILABLE';
r.errorMessage = 'ERP temporarily unavailable (HTTP ' + res.getStatusCode() + ')';
r.retryable = true;
} else {
r.success = false;
r.errorCode = 'HTTP_' + res.getStatusCode();
r.errorMessage = 'Unexpected HTTP status from ERP';
r.retryable = false;
}
return r;
}
public class Request {
@InvocableVariable(required=true) public String orderNumber;
}
public class Result {
@InvocableVariable public Boolean success;
@InvocableVariable public String orderData;
@InvocableVariable public String errorCode;
@InvocableVariable public String errorMessage;
@InvocableVariable public Boolean retryable;
}
}
Heads-up — callout inside a loop: this example fires one callout per iteration because the legacy ERP in this scenario exposes one endpoint per order number — there is no way to batch the requests into a single call. When the target service supports bulk operations, the right approach is to bulkify: build all requests first and make one callout (or as few as possible). One callout per loop iteration can exhaust the 10-callout-per-synchronous-transaction limit quickly under any meaningful volume. This snippet is an illustrative example of the failure classification pattern — not a production template to copy wholesale.
The retryable flag gives the agent structured information to make a decision rather than asking it to infer retry safety from an exception message. Pair this with a circuit-breaker pattern on Custom Metadata (as described in Part 2) and you prevent the agent from hammering a degraded system.
Safe Write Patterns
Read operations are forgiving. Write operations are not.
When an agent writes to an external system, you need to answer two questions before the write happens: "Will this write create a duplicate if it's executed twice?" and "What is the state of this record right now?"
Idempotency keys address the first question. Assign each agent-initiated write a correlation ID that you generate once and reuse across retries. On the ERP side, the endpoint should check whether a request with that correlation ID has already been processed and return the existing result instead of executing the operation again. Many modern APIs support this natively (Stripe's Idempotency-Key header is the well-known example). Legacy ERPs often don't. When the target system doesn't support idempotency keys natively, you build the check on your side: store the correlation ID and the result in a Callout_Log__c record before making the callout, and check that record first on any retry.
Optimistic locking addresses the second question. Before writing, verify that the record you're about to modify is in the state you expect it to be — typically by checking a version field or a timestamp. If the state has changed since you last read the record, fail explicitly rather than overwriting a change you didn't know about. Most ERPs have some equivalent mechanism. If yours doesn't, implement a pre-write read and compare on the fields you're about to change. This adds a callout (eating into your 10-callout budget), but a silent overwrite of someone else's change is worse than an explicit conflict error.
What doesn't work: the double-write pattern, where you write to the ERP and immediately write the confirmation to Salesforce in the same action. If the ERP write succeeds but the Salesforce write fails — or if the Salesforce write succeeds but the ERP never gets the request — you have silent data inconsistency. Both systems believe they have the authoritative record, and they disagree. The correct pattern is to make the ERP write first, confirm success, then write to Salesforce. On ERP write failure, halt and surface a structured error. Never treat both writes as a single atomic operation when you can't enforce atomicity.
Context Volume Management
A multi-agent chain that reads from an ERP early in the sequence and then passes that data to downstream agents has a token problem. ERP responses are verbose. A single order record from SAP can easily be 15–20 KB of XML. Pass that through two or three agent hops and you're consuming a significant portion of the context window before the downstream agent has done anything.
The data minimisation pattern: transform ERP responses at the action boundary. The action that reads from the ERP should return only the fields the agent needs to make its next decision, not the full record. Implement this as a projection in the Apex action — parse the full ERP response, extract the relevant fields, return a lean JSON object.
// Instead of returning the full ERP response body
r.orderData = res.getBody(); // can be 20KB+
// Project to what the agent actually needs
Map<String, Object> erpData = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Map<String, Object> projection = new Map<String, Object>{
'orderId' => erpData.get('ORDER_ID'),
'status' => erpData.get('ORDER_STATUS'),
'total' => erpData.get('TOTAL_AMOUNT'),
'lineItemCount' => ((List<Object>) erpData.get('LINE_ITEMS')).size()
};
r.orderData = JSON.serialize(projection);
If downstream agents need the full record, design the architecture so they fetch it themselves using the order ID, rather than receiving the full payload through the chain. This keeps context payloads lean at every hop and reduces the risk of hitting the 32K token limit on downstream agents (a failure mode covered in Part 1).
For agent chains that do multiple ERP reads across a session, cache the results in a @AuraEnabled or @InvocableVariable context where appropriate, and pass identifiers rather than payloads between agents. The coordinator fetches context once; workers receive IDs and fetch what they specifically need.
A Concrete Read-Conditional-Write Pattern
The scenario: an agent reads an ERP order, evaluates whether it qualifies for expedited shipping based on account tier, and — if it does — updates the shipping priority in Salesforce and writes the flag back to the ERP. Two write targets. A conditional logic path. A callout budget to respect.
The action sequence that works within the 10-callout limit:
- Action 1 — Read ERP order (1 callout). Project to relevant fields. Return order data +
orderId. - Agent reasons about account tier and eligibility. No callout. No AWU from external I/O.
- Action 2 — Conditional write action. If eligibility is true: (a) write shipping priority to ERP (1 callout), (b) update the Salesforce Opportunity or Order record via DML (no callout — DML is not a callout). Return a structured success/failure result with the idempotency key used.
- Action 3 (if Action 2 failed with
retryable: true) — Retry action, using the same idempotency key from step 3. Checks theCallout_Log__crecord first to determine if the ERP write already succeeded before attempting the callout again.
Total callouts in the success path: 2. Total callouts in the retry path: 3. Well within the limit, with headroom for additional actions in the same chain.
The Salesforce DML in step 3b runs inside the action's transaction. If the DML fails after a successful ERP write, you write a compensation record to Agent_Dead_Letter__c (from Part 2's governance pattern) with the ERP write's idempotency key and the Salesforce record ID. A reconciliation job processes these records and either completes the Salesforce write or flags for human review. You don't try to undo the ERP write — you complete the Salesforce side, which is the safer direction of inconsistency.
That closes the Agentforce in Production series. Parts 1 through 3 cover the architecture decisions that actually matter when you take these implementations beyond the sandbox: orchestration patterns, governance and AWU management, and the integration layer that connects agents to real enterprise data.
Shipping something similar and running into different failure modes? LinkedIn.