{"slug": "why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch", "title": "Why Your AI Agent Sent That Message Twice, and What to Require Before Launch", "summary": "A developer argues that AI agents double-send messages, double-book and double-charge because the underlying infrastructure — webhooks, queues, workers and LLM API clients — defaults to at-least-once delivery, and that prompt-level instructions cannot fix it since the model often doesn't know a prior attempt occurred. The proposed fix is architectural: derive an idempotency key from the action's intent rather than the attempt, reserve it in an actions table with a unique constraint and pending status before calling out, and dedupe webhook events at the edge before invoking the model.", "body_md": "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.\n\nFounders 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.\n\nNone 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.\n\nA 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.\n\nThis 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](https://pykero.com/blog/ai-feature-reliability-deterministic-guardrails): the model proposes, the code decides whether that action is still allowed.\n\nYou 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.\n\nBefore 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](https://docs.stripe.com/api/idempotent_requests)). Your agent's send function should behave the same way, whether the downstream API supports keys natively or you enforce it yourself.\n\nThe 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](https://www.postgresql.org/docs/current/ddl-constraints.html)). 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.\n\nWebhook 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](https://pykero.com/blog/ai-whatsapp-sales-agent-cost).\n\nOur 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](https://pykero.com/blog/single-call-vs-agent-chains).\n\nPut these in the statement of work or the vendor contract. Each is a yes or no question with a demonstrable answer.\n\n`pending`.\nMaking 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.\n\nIf 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](https://pykero.com/#contact).\n\n*Originally published on the [Pykero blog](https://pykero.com/blog/ai-agent-duplicate-actions-idempotency).*", "url": "https://wpnews.pro/news/why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch", "canonical_source": "https://dev.to/pykero/why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch-1g29", "published_at": "2026-10-23 09:05:15+00:00", "updated_at": "2026-10-23 09:11:37.294645+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "artificial-intelligence"], "entities": ["Stripe", "PostgreSQL", "WhatsApp"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch", "markdown": "https://wpnews.pro/news/why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch.md", "text": "https://wpnews.pro/news/why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch.txt", "jsonld": "https://wpnews.pro/news/why-your-ai-agent-sent-that-message-twice-and-what-to-require-before-launch.jsonld"}}