← Blog
Jascha Kaykas-Wolff··6 min read

AI SDR Duplicate Emails: Stop an Outbound Agent From Sending Twice

Most duplicate emails from an AI SDR start at one moment: the send call times out after the mail server already accepted the message, and the agent tries again. The guard that holds is a key made of the contact plus the sequence step, checked inside the send tool, with a Sent-folder search before any retry. Telling the model in its prompt not to repeat itself does nothing for a timeout.

Same email, four seconds apart.

That is how a duplicate usually lands, and the prospect notices before you do. A first touch that arrives twice reads as a machine that lost count, which is the one impression a personalized cold email is supposed to avoid. We have mentioned this failure three times on this site already (in the MCP tool permission matrix, the kill switch guide and the audit log), each time as a footnote to something else. It gets its own page here because the fix sits somewhere most teams do not look.

Where the second copy comes from

Email had this bug long before anyone called software an agent. RFC 1047, written by Craig Partridge in 1988, describes it for plain SMTP: the sender transmits the final dot, the receiving server accepts the message, and the connection drops before the OK comes back, so the sender cannot tell the mail went through and sends it again. An outbound agent keeps that old problem and adds a few of its own.

A timeout on a send that worked

Send tool

Gmail, the MCP server in front of it, or a sequencing tool’s endpoint answers slower than the agent’s tool timeout. The message already left. The agent sees an error and tries again, often within seconds, and the Gmail API’s send method takes no idempotency key that could refuse the second call.

The model redoes a step it already did

Agent loop

Long sessions get summarized or restarted. If the summary drops the line saying step two went out at 9:02, the model reads its plan fresh and step two looks undone. It has no memory of the send beyond what the context says, and the context stopped saying it.

Two runs overlap

Scheduler

The 9:00 run starts while the 8:00 run is still waiting on a slow CRM. Both pick up the same list of due steps.

One person, two addresses

List building

jane@acme.com from one data source, j.smith@acme.com from another, enrolled in two sequences a week apart. Two contacts in your database. One irritated human.

A restart releases held mail

Kill switch

A campaign comes back from a pause, the agent sends the emails it was holding, and the sequencing tool sends its own scheduled copies of the same steps.

The first cause needs nothing to go wrong except a slow API on an ordinary Tuesday. That is why we build for it first.

Build the key into the send tool

Payment APIs solved this years ago. Stripe accepts an Idempotency-Key header on POST requests, and a retry carrying the same key gets the first result back instead of charging the card twice. Sending email through Gmail offers no equivalent, so you build it one layer up, in the tool your agent calls.

The key is the contact ID plus the sequence step ID. Before sending, the tool writes that key with a status of pending. If a row with the key already exists, the tool refuses and returns what it found. After a successful send it stores the provider's message ID on the same row.

Our firmest opinion on this page: keep the draft out of the key. Hashing the email body is tempting, because the text feels like the most exact identity a message can have, but an agent that retries usually writes the draft again from scratch, the new draft hashes to a different value, and the guard waves through the very duplicate it was built to stop. Keep the timestamp out for the same reason.

Now the hard case. A pending row with no message ID means a send that may or may not have happened, and the tool should not guess. It searches the mailbox first, with a query like in:sent to:jane@acme.com newer_than:1d, and sends only if that comes back empty. This is the intent-first record from our audit log guide, put to work.

Writing "never send the same email twice" into the system prompt is harmless. It is also useless here, because after a timeout the model has no idea the first send worked.

Duplicate-send guards compared

Each guard covers a different cause. None covers all five.

GuardCatchesMissesWhere it livesBuild effort
Key per contact and sequence step, checked in the send toolTimeout retries, repeated steps, overlapping runs, restartsThe same person under a second addressYour send tool, plus one tableSmall if you own the tool
Sent-folder search before any retryThe ambiguous case: a send that may or may not have gone outCopies sent from a different mailboxSend toolSmall
One run at a time per campaign (a lock)Overlapping scheduled runsRetries inside a single runSchedulerSmall
Person-level dedupe at enrollmentOne human under two addresses, one person in two sequencesEverything that happens after enrollmentEnroll tool and CRM lookupMedium, needs domain and name matching
The sequencing tool’s own duplicate checkAn address added to the same campaign twiceSends the agent makes outside the tool; other campaigns, depending on settingsInstantly, Smartlead, Outreach and similarNone
A line in the promptNothing we could measureTimeouts, restarts, overlapping runsSystem promptNone

Where each guard fits (and where it does not)

If your agent never calls a send API and only enrolls contacts into a sequencing tool that does the sending, you have less to build than this page suggests. Let the tool handle duplicates inside a campaign. It has been doing that job for years and will do it better than anything you write this week. Your risk moves to enrollment, so put the effort into the person-level check and into making the enroll tool itself refuse a second enrollment for the same contact.

If the agent sends through the Gmail API or Microsoft Graph directly, nothing downstream will catch a retry for you. Build the key and the Sent-folder search before the first live send.

The run lock is cheap. Add it either way.

Person-level dedupe is the slow one to get right, because it needs the same company-domain matching our exclusion list guide uses to keep the agent away from customers. For a first campaign of a few hundred contacts, a person skimming the enrollment list sorted by company domain will catch most of what that code would.

If you would rather watch an agent run before deciding how much of this to build yourself, that is what a pilot is for. Ours is free: we run your first campaign, you see who was contacted and when, and the free pilot costs nothing if it does not work. After that, pricing is per qualified meeting, agreed with you.

Break it on purpose before launch

A normal shadow mode run will almost never hit this bug, because nothing times out on a quiet test day. Make it happen. Add a delay to the send tool so it times out after writing its pending row, and watch whether the retry finds the row or sends anyway. Kill the agent halfway through a run and restart it. Start a second run while the first is still going. Then count rows per key. Any key with more than one would-have-sent row is an email you would have sent twice.

What the prospect sees

Two identical emails with your name on them, the second arriving while they are still deciding whether a person wrote the first.

Why we care

After the free pilot we get paid per qualified meeting, and a qualified meeting is one where the prospect shows up. Nobody books a call with a sender who just proved they are a script. Mira runs 24/7 on OpenClaw from a Mac mini in San Francisco, so the timeout that causes a duplicate tends to happen when nobody is watching. Our CRM writeback guide covers the same retry problem from the CRM side, and how Mira works walks through the rest, from first email to booked call.

Jascha Kaykas-Wolff founded Mira Outbound and runs Mira, the outbound agent described across this site, on OpenClaw. These guides come from the guardrails that agent actually uses. kaykas.com