Durable Outbound Delivery

Persist outbound bot messages with retry, idempotency, and crash-safe drain on restart

python
from praisonaiagents import Agent

agent = Agent(name="bot-agent", instructions="Reply on messaging channels.")
agent.start("Send the user a confirmation once the job completes.")

Durable Delivery persists every outbound bot message to a SQLite outbox so retries, restarts, and platform outages never drop a reply. For permanently dead targets — bot kicked, chat deleted — see Dead Target Registry, which suppresses doomed sends instead of queuing them.

The user expects a bot reply; durable delivery queues outbound messages, retries transient failures, and drains after restarts.

Diagram

Durable Outbound Delivery

success

transient fail

🤖 Agent

📤 send_durable

💾 OutboundQueue

🔁 deliver_with_retry

✅ mark_sent

⏳ drain_pending

Quick Start

  1. Easiest path — DurableAdapterMixin

    Add three lines to any existing adapter and every send_durable() call is crash-safe:

    python
    import os
    from praisonaiagents import Agent
    from praisonai.bots import TelegramBot
    
    # TelegramBot already supports durable delivery via DurableAdapterMixin
    agent = Agent(name="assistant", instructions="Help users")
    bot = TelegramBot(
        token=os.getenv("TELEGRAM_BOT_TOKEN"),
        agent=agent,
    )
    
    # Replay anything queued before the last crash.
    # Also fires automatically on channel recovery — no need to wire it yourself.
    # Manual call is only needed for adapters not driven by ChannelSupervisor.
    await bot.drain_outbox()
    
    # Send with durability
    await bot.send_durable(
        channel_id="12345",
        content="Hello! I'm crash-safe.",
        idempotency_key="welcome-msg-12345",
    )
  2. Configure manually with DurableDelivery

    For full control, wire up OutboundQueue and DurableDelivery directly:

    python
    import os
    from praisonaiagents import Agent
    from praisonai.bots import OutboundQueue, DurableDelivery, TelegramBot
    
    outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")
    adapter = TelegramBot(
        token=os.getenv("TELEGRAM_BOT_TOKEN"),
        agent=Agent(name="assistant", instructions="Help users"),
    )
    delivery = DurableDelivery(
        outbox, adapter, platform="telegram",
        # mark_recovered=True,  # opt-in: prefix crash-recovered re-sends with a "possible duplicate" marker
    )
    
    # On startup: replay anything queued before the last crash
    succeeded, failed = await delivery.drain_pending()
    
    # Send with durability — idempotent if you reuse the key
    success = await delivery.send(
        channel_id="12345",
        content="Hello, world!",
        idempotency_key="msg-123",
    )

How It Works

Diagram
📱 Platform API💾 OutboundQueue🤖 Bot Adapter📱 Platform API💾 OutboundQueue🤖 Bot Adapterstatus = ‘failed’drain gates by max(backoff, server_retry_after)status = ‘permanent_failure’alt[success][transient failure][permanent failure]enqueue(key, channel, payload)tracking keyattempt delivery200 OKmark_sent(key)429 (retry_after: 30)mark_failed(key, permanent=False)drain_pending()retry after 30s200 OKmark_sent(key)400 Bad Requestmark_failed(key, permanent=True)

Each message moves through statuses: pending → sending → sent (or failed / permanent_failure).

State Machine

The outbox tracks six statuses: pending, sending, recovered, sent, failed, and permanent_failure.

On restart, stale sending entries transition to recovered instead of pending. This preserves the information that the send was in-flight when the crash occurred.

status = 'sending'  (crash)  ──►  status = 'recovered'  (on restart)

recovered entries are retryable like pending, but when a reconciler is supplied to drain(), they are offered to it first — allowing adapters that can check the platform to confirm delivery and avoid re-sending an already-delivered message (effectively-once). Without a reconciler, recovered entries are re-sent as normal (at-least-once, unchanged behaviour).

Age-gated dead-lettering (attempts + wall-clock)

permanent_failure is now a two-condition decision, so a brief channel outage no longer silently drops deliverable messages.

An entry moves to permanent_failure when either:

  • The error class is known-permanent ("credential" or "permanent_target") — short-circuits immediately regardless of age.
  • Both attempts >= max_attempts and the entry has been in the queue for >= dead_letter_min_age (default 6h).

Otherwise the entry stays retryable and the next drain() reschedules it under the normal capped backoff.

The default 6-hour floor is far longer than any realistic channel incident and well under the 7-day retention TTL, so most operators need no config change — transient outages simply keep retrying until the channel recovers.

Diagram

credential / permanent_target

recoverable

Yes

No

drain sees exhausted entry

Known-permanent
error class?

💀 permanent_failure

Age ≥ dead_letter_min_age?

🔁 Reschedule under backoff

Restoring legacy behaviour — pass dead_letter_min_age=0 if you rely on attempt-count-only quarantine (e.g. poison-message unit tests):

python
from praisonai.bots import OutboundQueue

# Attempt-only dead-lettering (pre-#3521 behaviour)
outbox = OutboundQueue(
    path="~/.praisonai/state/outbox.sqlite",
    max_attempts=5,
    dead_letter_min_age=0,
)

Custom policy — inject any DeadLetterPolicyProtocol implementation for one-off decisions:

python
from praisonaiagents.gateway import AttemptAndAgeDeadLetterPolicy
from praisonai.bots import OutboundQueue

outbox = OutboundQueue(
    path="~/.praisonai/state/outbox.sqlite",
    dead_letter_policy=AttemptAndAgeDeadLetterPolicy(
        max_attempts=10,
        min_age_seconds=24 * 3600,  # allow a full day of retries
    ),
)

Introduced in PraisonAI PR #3521.


Effectively-Once Delivery

Adapters that can confirm whether a prior send actually landed can upgrade durable delivery from at-least-once to effectively-once.

Diagram
📱 Platform💾 OutboundQueue🤖 Adapter📱 Platform💾 OutboundQueue🤖 Adapterstatus = ‘sending’on restart → ‘recovered’alt[Provider says delivered][Provider says not delivered]💥 crash mid-senddrain_pending() (with reconciler)reconciler(entry)was_delivered(idempotency_key)TrueTrue → mark_sent (NO resend)Falseresend
  1. Declare the capability

    Set reconciles_unknown_send=True on PlatformCapabilities:

    python
    from praisonaiagents.bots import PlatformCapabilities
    
    class MyAdapter(DurableAdapterMixin):
        platform_capabilities = PlatformCapabilities(
            reconciles_unknown_send=True,
        )
  2. Implement was_delivered

    Add an async method that checks the platform for a prior send:

    python
    async def was_delivered(self, idempotency_key: str) -> bool:
        status = await my_provider.get_message_status(idempotency_key)
        return status == "sent"
  3. Call drain_pending() at startup

    DurableDelivery auto-wires the reconciler from the adapter’s capability — no extra configuration required:

    python
    from praisonaiagents.bots import PlatformCapabilities
    from praisonai.bots._adapter_base import DurableAdapterMixin
    
    
    class AcmeAdapter(DurableAdapterMixin):
        platform_capabilities = PlatformCapabilities(
            reconciles_unknown_send=True,
        )
    
        async def was_delivered(self, idempotency_key: str) -> bool:
            status = await acme_client.messages.lookup(idempotency_key)
            return status.delivered
    
        async def send_message(self, channel_id: str, content: str) -> None:
            await acme_client.messages.send(channel_id, content)
    
    
    # DurableDelivery picks up the reconciler automatically.
    succeeded, failed = await adapter.drain_outbox()

Choosing the Right Primitive

Diagram

Yes, I have an adapter

Yes, full control

No persistence needed

Yes

No

Yes, on by default

Manual control

What do I need?

Survive crashes\nand restarts?

DurableAdapterMixin

DurableDelivery

Message too\nlong for platform?

deliver_chunked

Need retry on\ntransient 429/503?

OutboundResilienceMixin

deliver_with_retry

I need to…Use
Auto-retry transient failures — no config, works everywhereOutboundResilienceMixin (on by default in every adapter)
Manual retry with explicit backoff policydeliver_with_retry()
Send a message that may exceed platform length limitsdeliver_chunked()
Survive process crashes / platform outages with replayDurableDelivery or DurableAdapterMixin
Build a brand-new custom adapter with durability built inDurableAdapterMixin

Configuration Options

OutboundQueue

SQLite-backed outbox. All parameters after path are keyword-only.

ParameterTypeDefaultDescription
path`str \Path`_(required)_SQLite file path. Parent dirs are created automatically.
max_sizeint50_000Max entries kept. Oldest sent entries are evicted when exceeded.
ttl_secondsint604800 (7 days)Sent entries older than this are evicted.
max_attemptsint5Max delivery attempts before a message is eligible for permanent failure. Since PR #3521, exhausting attempts alone is no longer enough to dead-letter a transient failure — the entry must also satisfy dead_letter_min_age. A known-permanent error (credentials, permanently-invalid target) still short-circuits immediately.
dead_letter_min_agefloat (seconds)21600 (6h)Minimum wall-clock age since first receipt before an attempt-exhausted transient failure is marked permanent_failure. Set to 0 to restore the pre-#3521 attempt-only behaviour.
dead_letter_policy`DeadLetterPolicyProtocol \None`NoneOptional custom praisonaiagents.gateway.DeadLetterPolicyProtocol implementation. When supplied, overrides max_attempts + dead_letter_min_age.
backoffBackoffPolicyBackoffPolicy()Retry backoff configuration.
ordering`”strict” \“best_effort”`"best_effort"Per-conversation FIFO gate. strict holds later same-lane messages until the head reaches sent or permanent_failure. See Outbound Ordering.

OutboundQueue.drain() signature

python
async def drain(
    self,
    sender: Callable[[str, Dict[str, Any]], Awaitable[bool]],
    limit: Optional[int] = None,
    *,
    reconciler: Optional[Callable[[OutboundEntry], Awaitable[bool]]] = None,
) -> Tuple[int, int]:
ParameterTypeDefaultDescription
senderasync callable_(required)_Async callable that sends the message.
limit`int \None`NoneMax entries to drain in this call.
reconciler`async callable \None`NoneCalled only for recovered entries. Returns True → mark sent without re-dispatch (effectively-once). Returns False → re-send as normal. Raises → falls back to re-send (logged at WARNING).
python
from praisonai.bots import OutboundQueue

outbox = OutboundQueue(
    path="~/.praisonai/state/outbox.sqlite",
    max_size=10_000,
    ttl_seconds=3 * 86400,  # 3 days
    max_attempts=3,
)

OutboundQueue.enqueue() signature

enqueue() accepts an optional keyword-only lane_key for per-conversation grouping. It defaults to target, so messages to the same chat share a lane under ordering="strict".

python
async def enqueue(
    self,
    idempotency_key: str,
    target: str,
    payload: Dict[str, Any],
    metadata: Optional[Dict[str, Any]] = None,
    *,
    lane_key: Optional[str] = None,   # defaults to target
) -> str:
    ...
ParameterTypeDefaultDescription
lane_key`str \None`None (= target)Custom conversation grouping. Pass a thread id or user id to order sends within a shared channel. See Outbound Ordering.

OutboundQueue.status_for() signature

status_for() returns the current status of the entry for an idempotency key.

python
def status_for(self, idempotency_key: str) -> Optional[str]:
    ...
ParameterTypeDescription
idempotency_keystrThe stable key used at enqueue() time.

Returns — the current entry status ("pending", "sending", "recovered", "sent", "failed", or "permanent_failure"), or None if no entry exists for that key.

Use it to tell an already-delivered duplicate (status "sent") apart from a genuine delivery failure after a drain — so a deduplicated re-fire is reported as a suppressed success rather than a spurious failure.

python
from praisonai.bots import OutboundQueue

outbox = OutboundQueue(path="~/.praisonai/state/gateway_outbox.sqlite")
await outbox.enqueue(
    idempotency_key="daily-brief-2026-07-20",
    target="telegram:-100123",
    payload={"text": "Morning brief ready."},
)
await outbox.drain(sender)

if outbox.status_for("daily-brief-2026-07-20") == "sent":
    print("Delivered (or suppressed as a duplicate of a prior send).")

DurableDelivery

Wraps an OutboundQueue and an adapter to provide a simple .send() / .drain_pending() API.

ParameterTypeDefaultDescription
outboxOutboundQueue_(required)_The outbound queue to persist messages.
adapteradapter instance_(required)_Bot adapter with a send_message(channel_id, content) method.
platformstr""Platform name for platform-aware error classification.
backoffBackoffPolicyBackoffPolicy()Retry backoff configuration.
max_attemptsint3Max delivery attempts.
mark_recoveredboolFalseWhen True, a crash-recovered message re-sent without positive reconciliation is prefixed with a visible “possible duplicate after restart” marker instead of being re-delivered silently. See Labelling the at-least-once fallback.

DurableAdapterMixin.setup_durable_delivery()

Call once in your adapter’s __init__ to wire up the outbox.

ParameterTypeDefaultDescription
outbox_path`str \None`NonePath to SQLite outbox. None disables durability.
platformstr""Platform name for error classification.
max_attemptsint3Max delivery attempts per message.
max_sizeint50_000Max messages in outbox.
ttl_secondsint604800 (7 days)TTL for sent messages.
mark_recoveredboolFalseWhen True, label crash-recovered re-sends with a “possible duplicate after restart” marker instead of re-delivering silently. See Labelling the at-least-once fallback.

deliver_with_retry()

Bounded retry without persistence — on a recoverable failure, the delay is the server-mandated wait (server_retry_after(err)) when present, otherwise compute_backoff(policy, attempt).

ParameterTypeDefaultDescription
send_funcasync callable_(required)_Async callable to execute (the send operation).
policyBackoffPolicyBackoffPolicy(max_attempts=3)Retry backoff configuration.
is_recoverable`callable \None`NoneFunction to classify errors as transient. Defaults to is_recoverable_error(e, platform).
platformstr""Platform name for platform-specific error rules.
rate_limiterOptional[RateLimiter]NoneWhen supplied, a server-mandated wait (Telegram retry_after, HTTP Retry-After) triggers await rate_limiter.penalise(channel_id, delay) so subsequent sends to the same channel hold off until the platform’s window elapses.
parked_store`Any \None`NoneOptional DLQ for failed sends.
reply_data`dict \None`NoneOptional metadata for DLQ storage.
python
from praisonai.bots._delivery import deliver_with_retry
from praisonai.bots._rate_limit import RateLimiter
from praisonai.bots._resilience import BackoffPolicy

limiter = RateLimiter.for_platform("telegram")
ok, err = await deliver_with_retry(
    send_fn, channel_id="telegram-chat-12345",
    rate_limiter=limiter,
    policy=BackoffPolicy(initial_ms=2000, max_ms=30000, max_attempts=5),
    platform="telegram",
)

server_retry_after()

Extracts a server-mandated wait (seconds) from an error or response. Used internally by deliver_with_retry, ConnectionMonitor.record_error, and OutboundQueue.drain to honour explicit throttle signals over the policy backoff.

python
from praisonai.bots._resilience import server_retry_after

# Telegram RetryAfter
wait = server_retry_after(err)          # -> 30.0

# HTTP Retry-After header (seconds or HTTP-date)
wait = server_retry_after(http_err)     # -> 5.0 or seconds-until-date

# No hint present
wait = server_retry_after(generic_err)  # -> None
SourceWhere it’s readNotes
Telegram RetryAftererr.retry_after attributepython-telegram-bot exposes this directly.
Telegram raw API 429err.parameters["retry_after"]Raw API shape.
HTTP Retry-Aftererr.headers, err.response.headers, or err.resp.headersInteger seconds or HTTP-date (parsed via email.utils.parsedate_to_datetime; missing tz treated as UTC; clamped >= 0).
Text fallbackregex on str(err)Matches retry after <n> / retry_after: <n>.

Returns None when no hint is present — callers fall back to the policy backoff.

deliver_chunked()

Splits a long message at paragraph boundaries and sends each chunk separately. Returns the number of chunks sent.

ParameterTypeDefaultDescription
adapteradapter instance_(required)_Bot adapter with send_message(channel_id, content).
channel_idstr_(required)_Target channel.
contentstr_(required)_Message text to split and send.
max_lengthint4096Max characters per chunk (Telegram limit is 4096).
preserve_fencesboolTrueKeep code fence blocks intact even if they exceed max_length.
python
from praisonai.bots._chunk import chunk_message

# chunk_message is the underlying splitter
chunks = chunk_message(long_text, max_length=4096, preserve_fences=True)
for chunk in chunks:
    await adapter.send_message(channel_id, chunk)

BackoffPolicy

Controls retry timing for both deliver_with_retry and OutboundQueue.drain.

AttributeTypeDefaultDescription
initial_msfloat2000.0Initial delay in milliseconds.
max_msfloat30000.0Maximum delay in milliseconds.
factorfloat1.8Multiplicative factor per attempt.
jitterfloat0.25Random jitter fraction (0.0–1.0).
max_attemptsint0Max retry attempts. 0 = unlimited.

Idempotency & When the Outbox Drains

Idempotency Keys

Every message has an idempotency_key — a UUID generated automatically if you omit it. Reusing the same key for the same logical message prevents double-sends across retries.

python
# Webhook-triggered reply: derive key from inbound message ID
inbound_id = webhook_payload["message_id"]
await delivery.send(
    channel_id=chat_id,
    content=reply_text,
    idempotency_key=f"reply-{inbound_id}",
)

If the webhook is redelivered and send() is called again with the same key, the outbox skips the enqueue (SQLite UNIQUE constraint) and marks the existing row sent.

When the Outbox Drains

Held replies go out on three triggers, so operators can reason about when a queued message is actually re-attempted:

  1. Adapter startup — replays anything queued before the last crash (drain_pending() / drain_outbox()).
  2. Channel recovery (new — Issue #4043) — when ChannelSupervisor observes a channel come back after a recoverable outage (network blip, transient API error, stale socket), it fires drain_outbox() in the background so held replies go out promptly. Applications do not have to call anything.
  3. Lazy on next inbound turn — the next chat() turn also opportunistically drains.
Diagram

When the outbox drains

🚀 Adapter startup

💾 drain_outbox

🔁 Channel recovers

💬 Next chat turn

📤 Held replies go out

Call drain_pending() once at adapter startup to replay anything that was queued before the last crash:

python
# On adapter startup
succeeded, failed = await delivery.drain_pending()
# Log output: "Drained outbox: 3 sent, 0 failed"

# Or with the mixin:
succeeded, failed = await adapter.drain_outbox()

The drain replays oldest messages first and skips messages that have exceeded max_attempts. The retry gate is max(compute_backoff(policy, attempts + 1), server_retry_after(stored_err)) — the platform’s mandated wait survives across process restarts.

Recovery-triggered re-drain (Issue #4043)

Why did my held reply just go out on its own? When a channel drops on a transient outage, the outbox deliberately holds deliverable replies instead of dropping them. Before, those replies sat undelivered until the next inbound chat() turn or a process restart. Now ChannelSupervisor re-drains that platform’s outbox the moment the channel reconnects — see Channel supervision → Recovery-triggered outbox re-drain.

Recovery only — not a clean start

The re-drain fires only when the channel comes back from at least one recoverable failure (monitor.attempt > 0). A cold, clean first start does not trigger it — the startup drain still covers that path.

Background-scheduled — never blocks recovery

It launches as an asyncio.create_task(...) and is not awaited by the supervision loop, so a slow or large re-drain can never delay the channel coming back online.

Duck-typed and best-effort

The supervisor resolves drain_outbox on the supervised object first, then falls back to bot.adapter.drain_outbox. No hook → silent no-op. An exception is logged at WARNING and swallowed — the outbox’s own attempt-and-age dead-letter policy still governs those messages, so a failed re-drain never wedges supervision.

Bounded and idempotent

The re-drain is bounded by the outbox’s existing attempt-and-age policy and is safe to call more than once because the outbox itself dedupes on idempotency_key.

Log lines from the merged code:

# success (only when something moved):
Channel '<name>' recovered: re-drained outbox (<S> sent, <F> failed)

# failure (swallowed — retried on the next drain):
Channel '<name>' outbox re-drain on recovery failed (will retry on next drain): <exc>

Introduced in PraisonAI PR #4046 (fixes #4043).


Best Practices

Set reconciles_unknown_send=True only if you can answer the question reliably

A flaky was_delivered that returns False for an already-delivered message will cause a duplicate send. A reconciler that raises falls back to at-least-once re-send (safe, but logged at WARNING). Only opt in when your platform provides a reliable message-status API.

Don't rely on supports_idempotency_token alone for dedupe

supports_idempotency_token=True is informational only — the outbox does not forward the token on resend for most adapters. Adapters relying on provider-side deduplication should also set reconciles_unknown_send=True to get effectively-once delivery.

Exception — AgentMail. AgentMailBot.send_message declares an explicit idempotency_key parameter, so the durable layer forwards the persisted outbox key on every attempt (including a post-crash resend). AgentMail deduplicates that resend at the provider for 24h, giving AgentMail provider-level dedup even without a was_delivered reconciler. See Duplicate protection on retries.

Always set platform= for accurate error classification

The is_recoverable_error() function checks platform-specific patterns (e.g., Telegram’s HTTP 409 conflict, rate-limit “retry after” responses) when a platform name is provided. Without it, only generic patterns are checked and some transient errors may be misclassified as permanent.

python
delivery = DurableDelivery(outbox, adapter, platform="telegram")
Use a stable idempotency_key derived from the inbound message

When bridging a webhook to an outbound reply, derive the key from the inbound message ID. This ensures webhook redeliveries don’t produce duplicate outbound sends.

python
# ✅ Good: stable key tied to the inbound event
await delivery.send(
    channel_id=chat_id,
    content=reply,
    idempotency_key=f"reply-{inbound_message_id}",
)

# ❌ Bad: new UUID every call — no deduplication across retries
import uuid
await delivery.send(channel_id=chat_id, content=reply, idempotency_key=str(uuid.uuid4()))
Keep the outbox on persistent local disk

Store the outbox on a persistent, local filesystem path — not /tmp and not a Docker tmpfs. The default suggestion is ~/.praisonai/state/outbox.sqlite.

python
# ✅ Good: persistent path
outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")

# ❌ Bad: lost on restart or container rebuild
outbox = OutboundQueue(path="/tmp/outbox.sqlite")
Call drain_pending() exactly once per adapter start

Multiple concurrent drainers fight over the same rows via SQLite’s status = 'sending' claim mechanism. A 5-minute claim timeout releases stale claims, but concurrent drainers still produce redundant work and log noise.

python
# ✅ Good: single drain at startup
async def on_start():
    succeeded, failed = await delivery.drain_pending()

# ❌ Bad: two drainers in separate tasks
asyncio.create_task(delivery.drain_pending())
asyncio.create_task(delivery.drain_pending())
Only lower dead_letter_min_age with a specific reason

The 6-hour default is calibrated so a routine channel outage (Telegram 429 storm, Discord Cloudflare hiccup, WhatsApp API blip) never permanently loses a deliverable message, while a genuinely poisoned payload still dead-letters within the day. Only lower it when you have a specific need — e.g. dead_letter_min_age=0 inside a test suite where you want the pre-#3521 attempt-only behaviour, or dead_letter_min_age=60 in a synthetic burn-in that can’t wait 6 hours to observe the terminal state.

Import AttemptAndAgeDeadLetterPolicy from praisonaiagents.gateway

The praisonai-bot package’s dependency floor (praisonaiagents>=1.6.152) admits core releases that predate the shared AttemptAndAgeDeadLetterPolicy (first shipped 1.6.161). On those installs the queues fall back to a bundled LocalDeadLetterPolicy with identical semantics, so the age gate holds regardless of which core version is installed. If you customise the policy, prefer importing AttemptAndAgeDeadLetterPolicy from praisonaiagents.gateway — the import will succeed on any core ≥ 1.6.161.


Proactive Path

Direct BotOS.deliver(...) shares the reply-path rate limiter but is fire-and-forget. When the gateway itself performs the scheduled/proactive send, it runs through a durable outbox instead.

PropertyReply-path outbox (delivery.send)Direct BotOS.deliver(...)Gateway-hosted scheduled / proactive
PersistenceSQLite outbox, survives restartNot persisted — fire-and-forgetSQLite outbox at ~/.praisonai/state/gateway_outbox.sqlite, survives restart
Cross-worker dedupYes (UNIQUE on idempotency_key)❌ not supported✅ Yes (UNIQUE on the derived idem key)
Idempotency key✅❌ not a parameter✅ derived from sched:<channel>:<channel_id>:<session_id>:<text_digest>
Rate limiter shared✅ same bucket✅ same bucket (post PraisonAI #2578)✅ same bucket
Adapter was_delivered reconciliation✅❌ not applicableAvailable if the adapter opts in (same OutboundQueue.drain(reconciler=…) API)

Per-Adapter Delivery Guarantee

Only adapters that declare reconciles_unknown_send=True and implement was_delivered reach effectively-once; the rest stay at-least-once by design.

Adapterreconciles_unknown_sendDelivery guarantee
Slack✅Effectively-once (via metadata.event_type)
AgentMail❌At-least-once + provider-level dedup²
Telegram❌At-least-once
Discord❌At-least-once
Webhook❌At-least-once
HTTP❌At-least-once

² AgentMailBot.send_message(..., idempotency_key=…) forwards the outbox key as an AgentMail Idempotency-Key header (1–256 chars, 24h TTL). The outbox key survives crashes, so a re-send of the same outbox entry is deduplicated at the AgentMail provider — even without a was_delivered reconciler. See Duplicate protection on retries.

Slack is the first built-in adapter to implement the reconciliation seam end-to-end:

  • SlackBot declares reconciles_unknown_send=True on its platform_capabilities.
  • send_message(target, text, ..., idempotency_key=None) stamps the key into the Slack message metadata with event_type="praisonai_outbound".
  • was_delivered(target, idempotency_key) reads back recent history (conversations.history, or conversations.replies for a threaded send) and matches the key in metadata.event_payload. It returns False on any lookup failure, falling back to at-least-once.
Before: sending → (restart) → recovered → re-dispatched blindly → duplicate
After:  sending → (restart) → recovered → reconciler confirms → marked sent (no duplicate)

Related