All insights

Agentforce

Idempotent Agentforce actions: writing the action body so a retry doesn't double-fire

One customer complaint, three escalation cases. The Atlas reasoning loop can call the same action more than once per intent, and a write action with no guard turns that into duplicate records, duplicate emails, and duplicate charges. This is how to make the action body itself safe to call twice: upsert on an external ID, an idempotency ledger, and the pattern for callouts.

Idempotent Agentforce actions: writing the action body so a retry doesn't double-fire, article illustration

A service agent takes one angry message and opens three cases. Same contact, same reason, three records, three notifications to a team that now thinks three people are upset. Nobody wrote a bug. The action worked exactly as written, it just ran three times.

That failure mode shows up a few weeks into production, and the reflex fix is wrong. You cannot instruct the model out of it. The durable fix is to make the action body safe to call twice, and to put the guard in your data, not in the agent’s memory. An action that is idempotent produces one effect no matter how many times the reasoning engine calls it.

This post is that pattern in Apex: why the loop re-fires, why prompt wording won’t save you, and the three write patterns that make an action safe, for records, for side effects, and for callouts.

Why the same action fires more than once

The Atlas reasoning engine runs a reason-act-observe loop. It plans, calls an action, reads the result, and decides what to do next. Working out one user intent can take several passes, and across those passes it can call the same action more than once. The engine does that by design, not as a defect.

Retries make it worse. When a call looks like it failed, or times out, or returns something the planner can’t parse, the loop tries again. Nothing in that retry tracks whether the first attempt already ran its side effect. The model sees “no clean result” and calls the tool again. Your Apex sees two legitimate invocations and does the work twice.

For a read action, that is wasted effort and some credits. For a write action, it is duplicate cases, duplicate emails, or a customer charged twice. The read costs you money. The write costs you trust.

The fix that doesn’t work: telling the agent to be careful

The tempting fix is to add “only create one case per conversation” to the topic instructions or the action description. Sharpening those helps the agent pick the right action and pass better inputs, and that is worth doing. It does not make the write safe, because instructions are probabilistic and a duplicate write needs a guarantee.

Agent memory is not the guardrail either. You might assume you can set a context variable like “case already created” and check it next turn. You can’t lean on that. The predefined $Context variables are not editable after a session starts, except the end-user language, so you cannot flip your own flag mid-conversation and trust it. And even a writable custom variable lives in one session. It does nothing about a retry that fires before the variable is read, or a second conversation about the same order.

The guarantee has to live where writes are already serialized and constrained: the database. That is the one place two near-simultaneous calls can be forced to collapse into one record.

Pattern 1: upsert on an external ID for record writes

The cleanest idempotent write in Apex is an upsert against a unique external ID field. Derive a key from the inputs so the same intent always produces the same key, put that key on the record, and let the platform turn a second call into an update instead of an insert.

Add a custom field to the target object, marked External ID and Unique, then write the action to upsert on it:

public with sharing class CreateEscalationCase {
    public class Request {
        @InvocableVariable(required=true) public String conversationId;
        @InvocableVariable(required=true) public String reason;
        @InvocableVariable(required=true) public Id contactId;
    }
    public class Result {
        @InvocableVariable public Id caseId;
    }

    @InvocableMethod(label='Create Escalation Case (idempotent)')
    public static List<Result> run(List<Request> reqs) {
        List<Case> cases = new List<Case>();
        for (Request r : reqs) {
            // Key derived from inputs: the same intent yields the same key.
            String key = r.conversationId + ':' + r.reason.toLowerCase();
            cases.add(new Case(
                Idempotency_Key__c = key,   // External ID, Unique
                ContactId = r.contactId,
                Subject   = r.reason,
                Origin    = 'Agentforce'
            ));
        }
        // Upsert on the external ID: a repeat call with the same key updates,
        // it does not create a second Case.
        Database.upsert(cases, Case.Idempotency_Key__c, false);

        List<Result> out = new List<Result>();
        for (Case c : cases) {
            Result res = new Result();
            res.caseId = c.Id;
            out.add(res);
        }
        return out;
    }
}

The important line is the upsert with an external ID argument. Called once, it inserts. Called again with the same key, it finds the existing record and updates it, so you get one case and its ID both times. The reasoning loop can call this action five times and the customer still has exactly one escalation.

Two details make or break it. The key must be a pure function of the inputs, so the same request always rebuilds the same string. And the field must be Unique at the database level, because uniqueness is what serializes two concurrent upserts. A key based on System.now() or a random value defeats the whole thing, because every call looks new.

Pattern 2: an idempotency ledger for side effects you can’t upsert

Upsert covers records. It does nothing for a side effect that isn’t a row: sending an email, posting to a queue, kicking off a payment. For those you claim the key first, in its own object with a unique constraint, and only do the work if the claim is new.

String key = conversationId + ':refund:' + orderId;

Idempotency_Record__c claim = new Idempotency_Record__c(Key__c = key); // Key__c Unique
Database.SaveResult sr = Database.insert(claim, false);

Boolean firstTime = sr.isSuccess();
if (!firstTime) {
    for (Database.Error e : sr.getErrors()) {
        if (e.getStatusCode() == StatusCode.DUPLICATE_VALUE) {
            // Already done on an earlier call. Return the stored outcome,
            // don't fire the side effect again.
            return cachedResultFor(key);
        }
    }
}
// firstTime == true: safe to run the side effect exactly once.

The unique constraint is the lock. The first call inserts the claim and proceeds. A second call collides on the unique key, catches DUPLICATE_VALUE, and returns the earlier result instead of re-firing. Store the outcome against the key so the duplicate call can answer without redoing anything.

This ordering matters: claim, then act. If you act first and record the key afterward, a retry between those two steps runs the side effect twice, which is the exact bug you were trying to kill.

Pattern 3: pass an idempotency key to the external system too

When the side effect is a callout, your ledger protects your org, but the remote system can still receive two requests if a retry lands between your claim and their response. Most serious APIs solve this with an idempotency key header that you supply, so the provider dedupes on their side.

HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Payments/refunds');
req.setMethod('POST');
req.setHeader('Idempotency-Key', key);   // same key you claimed locally
req.setHeader('Content-Type', 'application/json');
req.setBody(payload);

Use the same key you claimed in your ledger. Now both ends dedupe on one value, and a refund fires once even if the request crosses the wire twice. Confirm the header name against the provider’s docs, because it varies, but the concept is near-universal for payment and messaging APIs.

The governor-limit angle, because loops are expensive

A re-firing loop is also a fast way to hit Apex governor limits. A synchronous transaction gets 100 SOQL queries and 150 DML statements, and an action that queries-to-check-then-inserts on every call burns through both faster than you expect when the engine loops.

Prefer upsert over a check-then-insert. A dedupe query followed by a conditional insert costs a SOQL and a DML and still has a race between them. The upsert is one DML and no race, because uniqueness does the checking. Fewer statements, and correct under concurrency.

Keep the action bulk-safe while you are in there. An invocable method receives a list of requests, so build one collection and do a single upsert, rather than a DML inside the loop. That habit is standard Apex custom action hygiene, and it keeps a batch of requests from multiplying your DML count.

Descriptions reduce the odds, idempotency removes the blast radius

These two layers are not competing. A sharp action description and a tight topic lower how often the loop calls the wrong thing, or calls the right thing twice. Good idempotency means that when it does, nothing breaks. Do both, and understand what each buys you.

Prompt wording changes the probability of a duplicate call. An idempotent action changes the consequence of one. You want the second guarantee, because the first will eventually fail.

One thing idempotency does not do is make an irreversible action safe to automate. A key stops a second refund, it does not decide whether the first refund should have happened. Genuinely consequential actions still belong behind a human-in-the-loop approval, with idempotency protecting the step once a human has said yes.

What I would do

Sort your actions into reads and writes first. Reads need no idempotency guard, so leave them alone and spend the effort where a double-call has a cost.

For every write action, decide what “the same operation” means in business terms, and build the key from exactly those inputs. A case per conversation-and-reason, a refund per order, an email per recipient-and-template. Then pick the pattern: upsert on an external ID for records, a claim-first ledger for other side effects, and the provider’s idempotency header for callouts.

Test it the way the loop will hit it: call the action twice with identical inputs in an Apex test and assert you get one record and one side effect. An agent that opens one case from one complaint, every time, is the difference between a demo and something you let near a customer.

Understanding the basics

Why does my Agentforce agent create duplicate records?

The Atlas reasoning engine runs a loop that can call the same action more than once while it works out a single user intent, and it retries a call that looks like it failed or timed out. Nothing in that retry tracks whether the first attempt already ran its side effect, so a write action with no guard inserts a second record. The fix is to make the action idempotent, most simply by upserting on a unique external ID derived from the request inputs.

What does idempotent mean for an Agentforce action?

An idempotent action produces the same single effect no matter how many times it is called with the same inputs. Calling a create-case action once or five times with identical inputs should leave exactly one case. You achieve this by deriving a stable key from the inputs and using it to collapse repeated calls, through an upsert on an external ID, a unique-key ledger, or an idempotency header on a callout.

Can I use a context variable to stop duplicate actions?

Not reliably. The predefined $Context variables aren’t editable after a session starts except for the end-user language, and even a writable custom variable lives only in that one conversation and can be missed by a retry that fires before it is read. The dependable place to enforce “do this once” is the database, where a unique constraint forces two concurrent calls to collapse into one record.

How do I make an Apex callout idempotent in Agentforce?

Claim an idempotency key in a local record with a unique constraint before you make the callout, and skip the callout if the key already exists, returning the stored result instead. Then send the same key to the external API in its idempotency header so the provider also dedupes. Claiming the key before the callout, not after, is what stops a retry from firing the request twice.


Shipping an agent that takes governed actions and want the writes safe before it meets real traffic? Talk to us. Designing actions that stay correct when the reasoning loop calls them twice is part of how we build agents to the ISV bar.

Keep reading

All insights