- The duplicate send is not a glitch. It is a small circle: the step that records the work as done is a change the trigger is watching.
- The fix is not a better filter. It is a rule that decides whether this exact send has already happened, written down before the email goes out.
- The prompt build hands your automation's trigger and actions to an AI chat tool and asks it to trace the circle and name the key.
- The script build is a send-once wrapper: check a ledger, claim the send, then hand off to the mail provider. No AI at runtime.
- The schedule build runs a nightly audit that counts repeats, so the next one gets caught by you and not by a customer. Learn it once and you can automate your email workflows without holding your breath.
Welcome to The Loop. Every Wednesday we take one real, repetitive workflow that someone does by hand and unwind it into something you can run. This week the workflow is a fight, not a chore: the automation that keeps sending the same email twice, and the weekly ritual of apologizing for it.
Here is the idea that makes this series worth your time. The boring task you repeat every week is not just a chore. It is a specification in disguise. That is true of firefighting too. If you keep patching the same failure by hand, the patch you keep applying is a rule you already know, and the only reason a machine is not enforcing it is that nobody has written it down. The moment you can describe what "already sent" means, you can hand the checking off. Describing replaces doing.
So let us watch the double-send happen, count what it really costs, and then turn the fix into a build you can run by the end of the page.
The Loop, in Full
Before you can automate anything, you have to see the loop clearly, and in a troubleshooting week the loop is the fight itself. Picture Marcus, who runs operations at a twelve-person training company.
Marcus built something reasonable. When a new enrollment row lands in the sheet with Paid set to yes, an automation sends the welcome email with the course link, then writes today's date into the Welcomed column so everyone can see who has been taken care of. It worked beautifully for six weeks. Then a customer replied with a screenshot of three identical welcome emails, timestamped ninety seconds apart, and asked whether she had been charged three times.
What Marcus does next is the actual loop, and he has now done it four times this year:
- A customer or a teammate reports the duplicate, usually a day or two after it happened.
- He opens the run history and scrolls until he finds three runs where he expected one.
- He writes the apology, and checks the billing system to prove nobody was double charged.
- He pauses the automation, adds a filter or a condition meant to catch this particular case, and turns it back on.
- Six weeks later it happens again somewhere else, with a different trigger and the same shape, and step one starts over.
Nothing in that list is hard. That is exactly why it is worth automating. The reason the filter never holds is that a filter describes one path to the bug, and the bug has many paths. The write-back to the Welcomed column is a row update, and the trigger watches row updates. That is one circle. A timeout on the mail provider that gets retried after the message already went out is another. Two polling windows that overlap and pick up the same row is a third. Marcus is patching routes when the thing he needs is a gate, and gates get designed up front, which is the argument for writing the workflow spec before you build.
The Manual Tax
The real cost of a loop is never just the minutes on the clock. It is the minutes, plus the mistakes, plus the time the work sits waiting. With duplicate sends the minutes are the smallest line on the bill, which is what makes this one sneaky.
Start with the part you cannot apologize your way out of. Mailbox providers count complaints, and the thresholds are published: the Gmail Help center tells bulk senders to keep the spam rate reported in Postmaster Tools below 0.10% and to never reach 0.30%. A person who gets the same email three times is far more likely to hit the spam button than to write in, and complaints are scored against your domain, not against the automation that misfired. A handful of duplicate batches can push a healthy sending domain into the zone where the good mail stops arriving too.
Then add the two costs that hide behind the minutes:
- Errors. A duplicate is not a typo, it is a credibility event. The recipient does not think "their Zap misfired," they think "is my payment safe." Every duplicate that touches money or access generates a support conversation that costs more than the original email ever earned.
- Latency. The reflex after a double-send is to pause the automation, and a paused automation means the next forty enrollments wait for a human to notice. You trade one visible failure for a quiet backlog, which is the failure mode nobody reports.
An automation that cannot tell you whether it has already done something is not automated. It is just fast.
So the goal is not to stop this particular duplicate. The goal is to stop paying the tax: give the system a way to know what it has already sent, so the answer holds no matter which path the run arrives by. That single change is what turns a nervous automation into one you can leave alone, and it is the difference between a workflow you babysit and one that can genuinely take the repeat sending off your plate.
Unwinding the Loop
Automating starts with description, not code. Describe the loop precisely enough that a machine could follow it without you in the room, and most of the work is done before a single line is written. In a troubleshooting week the description is doing double duty: it is also the diagnosis, because writing down what "the same send" means is the step everybody skips.
Here is the fix, captured as a spec. Notice that the interesting parts are the decisions, not the steps:
| Part of the loop | What it is for this workflow |
|---|---|
| Trigger | A new enrollment row with Paid set to yes, and nothing else. Not any row update, not any change to the sheet. |
| Input | Recipient address, the template being sent, and the period the send belongs to (this cohort, this month, this order). |
| Decision: is this the same send? | Build one key from recipient plus template plus period. If that key is already in the ledger, stop. This is the whole fix in one row. |
| Decision: where does "done" get written? | Somewhere the trigger cannot see. A separate ledger tab or a small store, never the same sheet and column the trigger watches. |
| Decision: claim first or send first? | Claim first. Write the key, then send. A crash after the claim costs one missing email. A crash before it costs a duplicate. |
| Output | Exactly one welcome email per enrollment, plus a ledger row recording the key, the timestamp, and the provider's message ID. |
| Success signal | The count of ledger rows equals the count of paid enrollments, and a nightly check reports zero repeated keys. |
That table is the whole fix. Everything after this is just choosing how hands-off you want the machine to be. If you would rather answer questions than fill in a table, that is exactly how BYOBot turns a task into a spec, one question at a time. Write the middle three rows down and the duplicate has nowhere left to come from.
Describe the automation that keeps repeating itself and BYOBot will trace the circle, then build the send-once rule that closes it.
The Build: One Loop, Three Ways to Run It
The same described fix becomes three different builds depending on how much you want running on its own. Start at the top and move down only when you are ready. For this loop the first build is worth doing even if you never touch the others, because it tells you which circle you are actually in.
The Prompt
The prompt build is a diagnosis you paste into any AI chat tool, with no setup at all. You describe your trigger and your actions in plain language, and you get back the self-triggering path, the retry path, the overlap path, and a proposed key. It will not fix anything for you, but it turns "it randomly double-sends" into a named cause in about a minute, which is the part most people never get to.
You are auditing an automation that sometimes sends the same email twice. Here is the automation: TRIGGER: [describe exactly what the trigger watches] STEPS: [list every step in order, including anything it writes back] RETRIES: [does the platform retry failed steps? how many times?] SCHEDULE:[polling interval, or "instant"] Check for these four causes and tell me which apply, with the evidence: 1. Self-trigger: does any step create a change the trigger is watching? 2. Retry-after-send: could a timeout be retried after the email already left? 3. Overlapping runs: can two runs pick up the same record? 4. Multiple entry points: does more than one automation send this template? Then propose an idempotency key for this send, built only from values that stay identical across every retry of the same send. Explain in one line why each part of the key is there.
That prompt works the same in ChatGPT, Claude, or Gemini, because the prompt is never locked to one vendor. Run it before you change anything, because the filter you were about to add is probably aimed at the wrong circle. It is also a decent habit for any workflow you inherit rather than build, which is most of them once you start to automate the handoffs between your apps at any real scale.
The Script
The script is where the fix stops depending on anyone remembering it. This is an API task with no judgment in it, so the honest build is a plain script with no AI at runtime: a send-once wrapper that every send in your account goes through. It is more reliable than a browser agent and cheaper than a subscription, because all it does is check a list before it acts.
The ledger can be a separate tab read through the Google Sheets API, a table in whatever database you already have, or a key-value store. The send itself goes out through the Gmail API or your provider of choice. The pattern is borrowed wholesale from payments, where charging someone twice has never been acceptable: Stripe has taken an idempotency key on every request for years, for exactly this reason. Email deserves the same courtesy.
// pseudo-shape of the send-once wrapper BYOBot generates for you
async function sendOnce(recipient, template, period, body) {
const key = `${template}:${recipient.toLowerCase()}:${period}`;
// 1. CHECK: has this exact send already happened?
if (await ledger.has(key)) {
log(`skip ${key}`); // the duplicate dies here
return { sent: false, reason: 'already sent' };
}
// 2. CLAIM before sending, never after
const claimed = await ledger.claim(key); // atomic: fails if someone won the race
if (!claimed) return { sent: false, reason: 'claimed by another run' };
// 3. SEND, then record the provider's own message ID
try {
const res = await mail.send({ to: recipient, ...body });
await ledger.complete(key, res.messageId);
return { sent: true, messageId: res.messageId };
} catch (err) {
await ledger.fail(key, err.message); // visible, not silently retried
throw err;
}
}
Two details carry most of the weight. The claim is atomic, so two runs firing in the same second cannot both pass the check. And the failure path marks the claim failed rather than deleting it, so a human decides whether to resend instead of the platform deciding for you. You do not write this from a blank page: BYOBot generates the full version, including the ledger schema, the file to create, and the credentials to paste in. The same wrapper then guards every other template you send, which is what makes it worth an afternoon if you already automate your Gmail workflows in more than one place.
While you are in there, close the circle at its source too. Move the "done" marker out of the sheet the trigger watches, narrow the trigger so it fires on new paid rows rather than on any row update, and if your platform lets you exclude changes made by your own service account, turn that on. The wrapper is the gate. These three are the fence.
The Schedule
The schedule is the smallest leap of all once the wrapper runs clean, and for this loop it is not the send that goes on a timer. It is the audit. A nightly job groups the ledger by key, counts anything that appears more than once, and messages you if the number is not zero. You find out the same night rather than from a customer on Thursday. A cron job or a GitHub Actions workflow runs it for free, and if you would rather stay no-code, a scheduled scenario in a tool like Make does the same job on the same rhythm.
Here is how the three builds stack up, so you can pick your stopping point:
| Build | What runs it | Setup | Runs unattended? |
|---|---|---|---|
| The prompt | You, pasting into any AI chat tool | Minutes | No |
| The script | A send-once wrapper every send goes through | An afternoon, once | On demand |
| The schedule | A nightly audit of the ledger | A few extra minutes | Yes |
Steal This Build
Here is the whole fix as a spec you can hand to BYOBot or build yourself this afternoon. Copy it, change the tool names to match your stack, and you have your own version.
- Trigger: the genuinely new event only, never any change to the record.
- Key: recipient plus template plus period, and nothing that changes between retries.
- Check: if the key is in the ledger, stop and log the skip.
- Claim: write the key atomically before the send, not after it.
- Send: then record the provider's message ID against the key.
- Report: a nightly count of repeated keys, delivered to a human.
The loop you just watched is an email loop, but the shape is universal. Any action that costs something when it happens twice, whether that is a charge, a ticket, a Slack ping, or a row written to a warehouse, wants the same gate: a key that says what "the same thing" means, and a written record checked before you act rather than after. Learn it once and the whole category of "why did it do that twice" stops being mysterious.
Tell BYOBot about a loop in your week
Describe the task you keep doing by hand and BYOBot will design the full playbook: the prompt, the script, and the schedule that runs it for you.
Frequently Asked Questions
-
Almost always because the automation can see its own output. The step that records the work as done is itself a change, and the trigger is watching the place that change lands, so the run starts over. The other common causes are a retry after a timeout, where the message actually went out before the error, and two overlapping polls picking up the same record. All three are the same bug wearing different clothes: nothing in the system decides whether this exact send has already happened, which is the first thing to add when you automate the emails you send on repeat.
-
An idempotency key is a short string that answers the question, is this the same send as that one. Build it from the things that make a send unique and nothing else: the recipient, the template name, and the period it belongs to. Something like
welcome:[email protected]:2026-09works. Leave out timestamps and run IDs, because those change on every attempt and every attempt would then look brand new, which is exactly the problem you are trying to solve. -
Before. Write the key to the ledger first, then send. If the send fails you can release the claim or leave it for a human to review, and the worst case is one email that did not go out. If you claim after sending, any crash or timeout in the gap leaves the send unrecorded, and the next run repeats it. One missing email is a support ticket. Three copies of the same email is a deliverability problem, and the second one is much harder to walk back.
-
No. The fix is a wrapper, not a rewrite. You add a ledger the automation checks before it sends, move the done marker somewhere the trigger cannot see, and narrow the trigger so it only watches genuinely new work. Every other step stays exactly as it is. Most people finish this in an afternoon, and the wrapper then protects every other send in the same account, which is the point at which you can build automations without writing code and still trust them unattended.
