{"slug": "stop-your-agent-from-replying-twice-dedup-patterns", "title": "Stop Your Agent From Replying Twice: Dedup Patterns", "summary": "A developer has identified three distinct causes of the \"double-reply\" problem in email agents—webhook redelivery, concurrent workers, and shared inboxes—and has published a set of deduplication and locking patterns to prevent it. The fix requires an atomic check-and-set on processed message IDs, a per-thread lock on the message thread, and a double-check inside the lock to catch replies sent between the webhook arrival and lock acquisition. The developer notes that deduplication and locking are both necessary, as neither alone is sufficient to prevent duplicate responses under real load.", "body_md": "Ever watched an email agent reply to the same message twice? The recipient gets two near-identical responses seconds apart, screenshots them, and your carefully engineered assistant suddenly looks like a script with a stutter. Worse: under real load, this isn't a freak event. It's the default outcome if you haven't designed against it.\n\nThe double-reply problem has three distinct causes, and each one needs its own fix. Let's walk through them.\n\nFirst cause: **webhook redelivery**. Nylas — like most webhook providers — guarantees at-least-once delivery. If your endpoint doesn't return a `200`\n\nfast enough, or a transient network blip eats the response, the same `message.created`\n\nnotification shows up again. Process both, send two replies.\n\nSecond: **concurrent workers**. Your handler probably runs on multiple instances — Lambda invocations, ECS tasks, worker processes. Two of them can pick up the same notification at nearly the same instant and both start generating a reply.\n\nThird: **shared inboxes**. Two agents (or an agent and a human) watching the same mailbox can both decide a message is theirs to answer. This one isn't a duplicate event at all — it's a coordination problem, and it's the hardest to patch at the application layer.\n\nTrack which message IDs you've processed, and check before doing anything else:\n\n``` js\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  res.status(200).end();\n\n  const event = req.body;\n  if (event.type !== \"message.created\") return;\n\n  const messageId = event.data.object.id;\n\n  // Atomic check-and-set. If the key exists, bail.\n  const alreadyProcessed = await db.processedMessages.setIfAbsent(messageId, {\n    receivedAt: Date.now(),\n  });\n\n  if (alreadyProcessed) return;\n\n  await handleMessage(event.data.object);\n});\n```\n\nThe check-and-set must be atomic. In Redis that's `SET messageId 1 NX EX 86400`\n\n; in Postgres it's `INSERT ... ON CONFLICT DO NOTHING`\n\nwith a row-count check. Give the record a TTL of 24 hours — long enough that a webhook redelivered hours later still gets caught, short enough that the table doesn't grow forever.\n\nDedup alone isn't enough. Two workers can race past the check-and-set within the same millisecond window. A per-thread lock closes that gap:\n\n```\nasync function handleMessage(msg) {\n  // Acquire a lock on this thread. If another worker holds it, skip.\n  const lock = await db.acquireLock(`thread:${msg.thread_id}`, {\n    ttlMs: 30_000, // release after 30 seconds if the worker crashes\n  });\n\n  if (!lock.acquired) return; // someone else has it\n\n  try {\n    // Double-check: has a reply already gone out since this message arrived?\n    const thread = await nylas.threads.find({\n      identifier: AGENT_GRANT_ID,\n      threadId: msg.thread_id,\n    });\n\n    const latestMessage = thread.data.latestDraftOrMessage;\n    if (latestMessage && latestMessage.from[0]?.email === AGENT_EMAIL) {\n      return; // a prior worker or retry already replied\n    }\n\n    await generateAndSendReply(msg);\n  } finally {\n    await lock.release();\n  }\n}\n```\n\nThe double-check inside the lock is the part people skip, and it matters: between the webhook arriving and the lock being acquired, another worker might have already finished the job. Fetching the thread and inspecting its latest message — if it's from the agent's own address, bail — catches exactly that window. The 30-second TTL on the lock is your crash insurance; a worker that dies mid-reply shouldn't hold the thread hostage forever.\n\nDedup catches the same event delivered twice. Locking catches the same event processed simultaneously. You need both; neither substitutes for the other.\n\nThe cleanest answer to the coordination problem is to delete it. [Agent Accounts](https://developer.nylas.com/docs/v3/agent-accounts/) — Nylas-hosted mailboxes for AI agents, currently in beta — make this cheap: each agent gets its own address, its own inbox, and its own webhook stream.\n\n`sales-agent@agents.yourcompany.com`\n\ndoes outbound prospecting`support-agent@agents.yourcompany.com`\n\nhandles inbound support`scheduling@agents.yourcompany.com`\n\ncoordinates meetingsEach handler filters to its own grant — `if (msg.grant_id !== MY_GRANT_ID) return;`\n\n— and no two agents ever see the same message. When a human needs oversight, give them read-only [IMAP access](https://developer.nylas.com/docs/v3/agent-accounts/mail-clients/) instead of a second automated writer.\n\nHere's the failure mode the first three fixes don't cover: a logic bug. Outbound messages fire `message.created`\n\ntoo, so an agent that accidentally responds to its own mail enters a loop — reply triggers webhook triggers reply. A per-thread send cap is the circuit breaker:\n\n``` js\nconst recentSends = await db.recentAgentSends(threadId, { withinMinutes: 5 });\n\nif (recentSends >= 3) {\n  await escalateToHuman(threadId, \"reply rate limit hit\");\n  return;\n}\n```\n\nThree sends on one thread in 5 minutes means something's wrong; escalate instead of sending. And always filter the agent's own address at the top of every handler — it's one line and it prevents the whole class of self-reply loops.\n\nFor agent mailboxes, [rules](https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/) can pre-sort inbound mail server-side. Route automated notifications to a separate folder, block spam at the SMTP layer, and have your handler skip folders the agent shouldn't answer:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/rules\" \\\n  --header \"Authorization: Bearer $NYLAS_API_KEY\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"match\": [{ \"field\": \"from.domain\", \"operator\": \"equals\", \"value\": \"noreply.example.com\" }],\n    \"actions\": [{ \"action\": \"assign_to_folder\", \"value\": \"notifications\" }]\n  }'\n```\n\nLess noise reaching your handler means fewer chances for conflicting logic to fire.\n\n| Failure mode | What it looks like | The fix |\n|---|---|---|\n| Webhook redelivery | Same event, delivered twice | Atomic check-and-set on the message ID |\n| Concurrent workers | Same event, processed simultaneously | Per-thread lock + double-check inside it |\n| Shared inbox | Two writers, one mailbox | One agent per inbox; IMAP for human oversight |\n| Self-reply loop | Agent answers its own outbound mail | Sender filter + per-thread send cap |\n\nNotice that no single fix covers another row. Dedup does nothing against a race between workers; locking does nothing against a logic bug that replies to the agent's own messages. If you're operating at any meaningful volume, you want all four.\n\n**How long should dedup records live?** 24–48 hours. Long enough to catch a webhook redelivered hours later, short enough that the table stays small. A webhook for a message ID older than that is almost certainly a bug, not a redelivery.\n\n**Do outbound sends really fire webhooks?** Yes — `message.created`\n\nfires for messages the agent sends, not just messages it receives. That's why the self-reply filter (`if (sender === AGENT_EMAIL) return;`\n\n) belongs at the top of every handler, before any other logic runs.\n\n**Can I skip the lock if my handler is single-instance?** Today, maybe. But \"single-instance\" is a deployment detail, not an architecture guarantee — the day someone scales the worker pool to two, the race appears. The lock costs one Redis call; the double reply costs a customer screenshot.\n\nA single-threaded test will never surface any of this. The only reliable verification is synthetic load with concurrent webhook deliveries — fire the same payload at your endpoint from multiple connections and confirm exactly one reply goes out. And when you skip a duplicate, log it; silent skips turn debugging into archaeology.\n\nThe full recipe — including the thread double-check and TTL guidance — is in the [duplicate-reply prevention guide](https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/), with the upstream [reply-handling loop](https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/) next door.\n\nWhat's your dedup story? If you've shipped a webhook consumer that survived a redelivery storm — or didn't — I'd like to hear what finally fixed it.", "url": "https://wpnews.pro/news/stop-your-agent-from-replying-twice-dedup-patterns", "canonical_source": "https://dev.to/qasim157/stop-your-agent-from-replying-twice-dedup-patterns-2lo7", "published_at": "2026-06-12 12:37:25+00:00", "updated_at": "2026-06-12 12:42:10.356740+00:00", "lang": "en", "topics": ["ai-agents", "ai-products", "ai-tools", "ai-infrastructure", "mlops"], "entities": ["Nylas"], "alternates": {"html": "https://wpnews.pro/news/stop-your-agent-from-replying-twice-dedup-patterns", "markdown": "https://wpnews.pro/news/stop-your-agent-from-replying-twice-dedup-patterns.md", "text": "https://wpnews.pro/news/stop-your-agent-from-replying-twice-dedup-patterns.txt", "jsonld": "https://wpnews.pro/news/stop-your-agent-from-replying-twice-dedup-patterns.jsonld"}}