Skip to content
Blog

AI

Why Your AI Agent Sent That Message Twice, and What to Require Before Launch

AI agents double-send messages, emails and orders because the infrastructure under them retries by default. Here is what founders should require before an agent touches real customers.

By Pykero Agency · Engineering teamOct 23, 20267 min read

TL;DR

Duplicate messages are not an LLM bug, they are a retry bug: webhooks, queues and model calls all deliver at least once, so every external action an agent takes needs an idempotency key, a reservation step and a dedupe table. Require these before any agent sends to a real customer.

duplicate chat notifications phone, Why Your AI Agent Sent That Message Twice, and What to Require Before Launch

Your AI agent sent the message twice because something underneath it retried, not because the model "decided" to. Webhooks, queues, workers and LLM API clients all default to at-least-once delivery, and an agent that calls the outside world without an idempotency key will eventually double-send, double-book or double-charge. The fix is architectural and cheap compared to the damage, so it belongs in the requirements, not in a post-launch patch.

Retries are the default, not the exception

Founders tend to picture an agent as a single conversation: a customer writes, the model thinks, a reply goes out. In production it is a chain of hops, and nearly every hop will repeat itself under stress.

  • The inbound webhook. Messaging platforms resend a notification when your endpoint is slow to respond or returns an error. If your handler already started working before it timed out, you now have two copies of the same event in flight.
  • The queue. Standard queues such as Amazon SQS are explicitly documented as at-least-once: a message can be delivered more than once, and your consumer is expected to cope (AWS docs). Most managed agent runtimes sit on something equivalent.
  • The worker. A crash, a deploy or an out-of-memory kill mid-task means the job runs again from the start. Everything it already did, it does again.
  • The model call. Every serious LLM SDK retries on rate limits and transient errors. If the first attempt actually succeeded server-side but the response was lost, you pay twice and, worse, your code may act on both.
  • The tool call. The agent calls your send-message or create-order function. That function talks to a third-party API with its own timeouts. A timeout is not a failure; it is an unknown.

None of these is exotic. They are the normal behaviour of normal infrastructure. An agent that is "99% reliable" in a demo is being tested at a load where none of this fires. In month two, with a few thousand conversations a day, all of it fires.

Why the LLM makes this harder than a classic job

A conventional worker that retries at least behaves the same way the second time. An agent does not. If a worker re-runs a planning step, the model may pick a slightly different action: the first attempt sent "Your order ships Tuesday", the retry sends "Your order ships tomorrow" because the context window now includes a half-written state. The customer sees two confident, contradictory messages from the same business.

This is why prompt-level fixes fail. Telling the model "never send a message twice" assumes the model knows the first attempt happened. It often does not, because the failure occurred after the model returned and before your code recorded the result. Deduplication has to live below the model, at the boundary where your system touches the world. This is the same principle behind the deterministic guardrails we cover in reliability for AI features: the model proposes, the code decides whether that action is still allowed.

What exactly-once actually means for an agent

You cannot get exactly-once delivery from the network. You can get exactly-once effect by making every external action idempotent. Three pieces do the work.

An idempotency key per intended action

Before the agent sends anything, your code derives a key from what the action is, not from the attempt. For a reply, that is typically the inbound message ID plus the conversation step. For an order, the cart ID plus the customer. Two retries of the same intent produce the same key; two genuinely different actions do not. Stripe's API is the reference design here: the client supplies the key, the server stores the first result and returns it on repeats (Stripe docs). Your agent's send function should behave the same way, whether the downstream API supports keys natively or you enforce it yourself.

Reserve, then act, then record

The failure that bites hardest is "sent but not recorded". The agent calls the messaging API, the API accepts the message, your process dies before writing to the database, and the retry sees no record and sends again. The fix is to reserve first: insert the key into an actions table with status pending before calling out. A unique constraint on the key means a concurrent or repeated attempt fails at the insert and stops (PostgreSQL constraints). After the external call succeeds, update the row to sent with the provider's message ID. If you find a pending row older than your timeout, that is a reconciliation case, not a resend case: query the provider by key, or hold for a human.

Dedupe inbound events at the edge

Webhook handlers should acknowledge fast and store the event ID in a dedupe table with the same unique-constraint trick. A duplicate delivery hits the constraint and is dropped before any model is invoked. This also saves money: every duplicate you drop at the edge is an LLM call you never pay for, which matters at the volumes we discuss in what a WhatsApp sales agent costs.

What we learned on our own outreach engine

Our cold-outreach system scrapes each prospect's site with a self-hosted crawler and a local model, then drafts one tailored email per company. Early on we assumed the hard problem was writing quality, and it partly was. The pattern that won on both cost and quality was a single model call that extracts facts and drafts the email together, rather than a multi-step chain. An underrated benefit showed up later: a single call has one output to make idempotent, so the "one email per company" promise could be enforced with one reservation row keyed on the company domain. Each extra step in a chain is another place where a retry can double-fire, so collapsing the chain simplified the reliability story as much as it simplified the bill. We wrote up the drafting side in single call vs agent chains.

What to require before an agent touches a real customer

Put these in the statement of work or the vendor contract. Each is a yes or no question with a demonstrable answer.

  • Every outbound action has a named idempotency key and the derivation is documented per action type.
  • Reservation before send. Show the table, the unique constraint and the state machine. Ask what happens to a row stuck in pending.
  • Inbound dedupe at the webhook, with the retention window stated. Platforms can redeliver hours later.
  • Retry-safe model calls. The SDK retries are fine, but the surrounding code must not treat "no response" as "nothing happened".
  • A chaos test in acceptance. Kill the worker mid-send, replay the same webhook three times, force a timeout on the messaging API. Zero duplicates reach the test number. If the agency cannot run this test, they have not built for it.
  • Reconciliation, not resend, as the default when state is unknown. A human-visible queue for unknowns beats an automatic retry every time.
  • Observability on duplicates. A counter for constraint hits is your early warning that the infrastructure is retrying more than expected, long before a customer complains. Pair it with the alerting from monitoring missed agent runs.

The cost conversation

Making an agent retry-safe is not a large line item. For a typical sales or support agent with three or four action types, expect a few days of focused engineering, most of it in designing keys and the reservation flow, plus a day for the chaos tests. The expensive version is the one where you skip it: refunded double charges, a messaging number flagged for spam-like behaviour, and the slow erosion that happens when customers learn your agent is not quite trustworthy. If a proposal for an agent that sends real messages does not mention idempotency anywhere, that is the question to ask before signing.

If you are scoping an agent that will act on behalf of your business and want the reliability requirements written down before the build starts, let's talk.

ai agentsreliabilityidempotencywhatsapp

Frequently asked questions

Why did my AI agent send the same WhatsApp message twice?

Almost always because a webhook, queue or worker retried after a timeout and the agent had no idempotency key tied to the intended action. The model did its job; the plumbing ran it twice.

Can prompt engineering stop duplicate actions?

No. The prompt runs inside the retry. Deduplication has to live at the boundary where the agent calls the outside world, in code and in the database, not in instructions to the model.

How much does making an agent retry-safe add to a project?

Usually a few days of engineering per external action type, dominated by designing the key and the reservation step. It is far cheaper than the refunds, blocked numbers and trust damage from duplicates.

Is this a problem with managed agent platforms too?

Yes. Managed runtimes retry failed steps for you, which is helpful, but the idempotency of your own actions is still your responsibility. Ask the vendor exactly where deduplication happens.

How Pykero can help

Services related to this article.

Building something like this?

Pykero Agency designs and ships production web, mobile, SaaS, and AI products.

Talk to us

Discussion

Be the first to comment.

Related reading