Development

An Outbox Table for Agent-Triggered Side Effects

Antonio Evans9 min read

A cross-section of a database table drawn as a row of labeled slots (id, event type, payload, key, status).
Key points

Key points

  1. 1Writing the downstream call inside the agent's transaction creates a dual-write window that no retry can close, so record the intent in an outbox row and commit once.
  2. 2Derive the idempotency key at insert time from durable data (tenant, operation type, entity id, intent version) and store it on the row so every retry reuses the same value.
  3. 3A separate relay claims pending rows, calls the downstream API with that stored key, and retries with exponential backoff and jitter until it succeeds or moves to a dead-letter state.
  4. 4The guarantee only holds if the downstream service enforces the key itself and the relay treats a timeout after dispatch as an unknown outcome, not a failure.

Why agents make the dual-write problem worse

The standard transactional outbox exists because a database and an external system do not share a transaction coordinator. If you update your own state and then call an external API, a crash between the two leaves you inconsistent. Fail after the commit and the external effect never happens; fail after the external call and your database never records it. This is the dual-write problem, and the outbox pattern is the usual answer: write the message to a table inside the same local transaction, then publish from that table later (the transactional outbox analysis, CodeToByte's outbox guide).

Agents add three multipliers to this problem. They retry aggressively because persistence is what they are rewarded for, so a tool call that times out is often reissued without any check on whether the first call landed (Tool Calling Has a Tax). They run concurrently, so two agents can act on the same record at once; race conditions in multi-agent orchestration are the expected case rather than the edge case, and the classic pattern is two actors both passing a check before either acts, leaving shared state corrupted (Handling Race Conditions in Multi-Agent Orchestration, OWASP on race conditions). And they are non-deterministic, so the same intent can be expressed with slightly different arguments on a second attempt.

The practical consequence: you cannot ask the model to produce the idempotency key. A freshly sampled value identifies the retry as a brand new operation and defeats deduplication entirely. The orchestration layer must generate and persist the key outside model-controlled arguments, derived from a durable execution step (Agent Tool Call Retries). An outbox row is exactly that durable step.

The outbox table schema

One table, written inside the same transaction as the agent's state change. The key design decision is what goes in the key column.

CREATE TABLE agent_outbox (
  id              BIGSERIAL PRIMARY KEY,
  tenant_id       UUID        NOT NULL,
  operation       TEXT        NOT NULL,          -- e.g. 'create_invoice'
  entity_id       TEXT        NOT NULL,          -- the business object
  intent_version  INTEGER     NOT NULL DEFAULT 1,
  idempotency_key TEXT        NOT NULL,
  endpoint        TEXT        NOT NULL,
  payload         JSONB       NOT NULL,
  status          TEXT        NOT NULL DEFAULT 'pending',
  attempts        INTEGER     NOT NULL DEFAULT 0,
  next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  locked_until    TIMESTAMPTZ,
  last_error      TEXT,
  downstream_id   TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_outbox_key UNIQUE (idempotency_key)
);

CREATE INDEX ix_outbox_due
  ON agent_outbox (next_attempt_at)
  WHERE status = 'pending';

Four things earn their place here. The unique constraint on idempotency_key means a duplicate insert fails loudly at commit time rather than silently creating a second intent, which is the same first-writer-wins primitive that idempotency middleware relies on (Idempotency Patterns). intent_version handles the case where a human or an agent revises the intent before it has been dispatched: bumping the version produces a new key, so the revision is a distinct operation rather than a silent overwrite. next_attempt_at drives scheduling so no external timer is needed. The partial index keeps the claim query cheap as the table fills with completed rows.

FieldPurposeFailure it prevents
idempotency_key (unique)Stable identity for one logical effectDuplicate effects from retries and concurrent agents
intent_versionDistinguishes a revised intent from a retryOne order being charged twice after an amount change
statuspending, in_flight, succeeded, deadRows being reprocessed forever or dropped silently
attemptsDrives backoff and dead-letter cutoffInfinite retry loops and unbounded cost
locked_untilLease for multi-worker relaysTwo relays dispatching the same row
payloadFrozen copy of what was approvedThe relay sending arguments the approver never saw

That last point matters more than it looks. Freezing the payload at insert time means the relay sends exactly the arguments that were authorized, not whatever the model produced on a later turn.

Deriving the key at insert time

The key must be deterministic for the same intent and different for different intents. Identical arguments alone do not establish identical intent, because a caller may legitimately want two identical resources (Agent Tool Call Retries). So include something that identifies the logical step.

import hashlib, json

def derive_key(tenant_id: str, operation: str, entity_id: str,
               intent_version: int, approved_args: dict) -> str:
    canonical = json.dumps(
        {
            "tenant": tenant_id,
            "op": operation,
            "entity": entity_id,
            "v": intent_version,
            "args": approved_args,
        },
        sort_keys=True,
        separators=(",", ":"),
    )
    return f"{operation}:{hashlib.sha256(canonical.encode()).hexdigest()[:40]}"

Three properties to check before you ship this. It is stable: the same inputs always produce the same key, so a relay crash and restart reuses it. It is scoped: tenant_id in the hash prevents cross-tenant collisions if you ever share a downstream account. It is human-readable enough to debug: the operation prefix tells an on-call engineer what the row is trying to do without decoding anything.

Monday.com's API documentation makes the same point from the provider side. Keys should be unique per intended operation and deterministic for the same intent, and the docs explicitly warn against generating a new key on each retry, because deduplication then fails (monday.com idempotency). Their example key format for webhook-driven mutations is event id plus operation name, which is the same shape as the derivation above.

If the downstream takes a client-chosen resource id rather than an Idempotency-Key header, use the hash as that id. Stripe stores the key alongside the result and replays the original response for repeats, which is the behavior you are asking for even when the vendor does not expose that header (Stripe idempotent requests).

The relay loop: claim, call, retry

The relay is a separate process from the agent. It claims due rows, dispatches them with the stored key, and records the outcome. Claiming must be atomic, because two relay workers racing on the same row produces exactly the duplicate effect the outbox was meant to prevent.

WITH due AS (
  SELECT id FROM agent_outbox
  WHERE status = 'pending' AND next_attempt_at <= now()
  ORDER BY next_attempt_at
  LIMIT 20
  FOR UPDATE SKIP LOCKED
)
UPDATE agent_outbox o
SET status = 'in_flight',
    locked_until = now() + interval '60 seconds',
    attempts = attempts + 1
FROM due
WHERE o.id = due.id
RETURNING o.*;

SKIP LOCKED lets each worker take a disjoint batch, so no row is dispatched twice by two relays. The lease (locked_until) covers the nasty case where a worker dies mid-call: the row becomes claimable again after the lease expires, and because the key has not changed, the redispatch is safe as long as the downstream enforces it.

Once claimed, the call itself is short. Send the key in the Idempotency-Key header, treat any non-2xx as a failure, and classify before scheduling a retry. The classification table below is the one that saves the most wasted work.

OutcomeRetry?Action
2xxNoMark succeeded, store downstream_id
429 with Retry-AfterYes, after the stated delayKeep pending, set next_attempt_at
5xxYes, with backoffKeep pending
Timeout or connection reset after dispatchYes, same keyKeep pending; outcome is unknown, not failed
400, 401, 403, 404, 410NoMark dead, alert
409 conflict from the providerYes, short delayThe first call is still in flight downstream

The 409 row is worth pausing on. Monday.com returns a conflict with a Retry-After header when a request with the same key is already being processed, so the relay should wait the indicated time and retry rather than treat it as an error (monday.com idempotency). Hookdeck's guidance draws the same line: 5xx, timeouts, connection refused, and DNS failures are retriable; 4xx except 429 are permanent and should go straight to the dead-letter state rather than wasting attempts (Hookdeck retry best practices).

One subtlety in the timeout row: a timeout after dispatch does not tell you whether the effect committed. The payment service may have charged the card even though the caller never saw a response. That is why the redispatch must carry the same key rather than a new one, and why treating a timeout as final failure is the most dangerous bug in the whole relay (Agent Tool Call Retries, Stripe idempotent requests).

The full flow in one picture:

agent transaction:  update state + insert outbox row (status=pending)
      |
      v
relay claim:        status=pending, locked_until=now()+60s, attempts+=1
      |
      v
dispatch:           POST endpoint with Idempotency-Key = row.idempotency_key
      |                      |
      v                      v
   2xx                  5xx / 429 / timeout
 mark succeeded         retryable=true?
 store downstream_id      |          |
                       yes|         no|
                          v          v
                  backoff + set   status=dead,
                  next_attempt_at  alert + replay tool

Dead rows are not garbage. A dead-letter state preserves the event, its error context, and the metadata needed to debug and replay it once the underlying problem is fixed, instead of letting a burst of failures block the healthy rows behind them (Dead-Letter Queues for Webhook Reliability, Webhook Reliability: Retry and Dead Letter Queues). Give the relay a replay command that resets a dead row to pending with attempts = 0 and keeps the key unchanged.

Backoff, jitter, and the arithmetic that decides your retry window

Fixed-interval retries hammer a struggling endpoint and slow its recovery. Exponential backoff increases the gap between attempts, and a cap keeps the schedule finite (Svix retry best practices, Mastering Webhook Retry Logic).

delay = min(cap, base * 2 ** attempts)
jitter = random.uniform(0, delay)      # full jitter
next_attempt_at = now() + jitter

Work the numbers. With a base of 2 seconds and a cap of 1 hour, attempt 5 waits at most 32 seconds, attempt 10 at most 17 minutes, and every attempt past 11 waits at most an hour. Ten attempts with full jitter fit inside roughly 3 to 4 hours of wall-clock time, which covers the redeploy-and-recover window of most downstream services. For comparison, Svix publishes a schedule that spans 8 attempts over roughly 24 hours: immediate, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, then 10 hours (Svix retry best practices). If your downstream is a payment or ticketing API with SLA-backed recovery measured in minutes, a 24-hour spread is overkill; if it is a partner system that goes dark for hours, it is about right. Match the window to the recovery time you can actually observe, not to a default.

Jitter is not decoration. When many rows fail at once during an outage, they all back off on the same schedule and retry at the same instant, which can knock the recovering service over again. Full jitter spreads them across the whole interval; equal jitter guarantees at least half the delay while still dispersing the wave (Hookdeck retry best practices, Webhook Retry Best Practices for Sending Webhooks). The thundering herd is a real production failure mode, not a theoretical one.

The decision rule for choosing between strategies: use full jitter when many rows share the same downstream and failures correlate, equal jitter when you need a predictable floor for latency-sensitive work, and a published fixed schedule when external teams need to reason about your timing.

What the downstream must do, and how to test it

None of the above helps unless the receiving service enforces the key. A local transaction does not include the provider's commit, so the only way to close the gap is to carry the operation's identity through to a downstream contract that deduplicates, or to leave the outcome for reconciliation (Agent Tool Call Retries). Where the mutation and the deduplication record share a transactional store, commit them together; otherwise a crash between changing state and recording completion leaves a hole. The order matters too. A commit-then-call relay can lose the effect if it crashes between the commit and the call, which is precisely why the outbox row exists. If you skip the outbox and call the downstream first, a failure before your commit leaves a phantom effect the database never records (CodeToByte's outbox guide).

Testing is where most teams learn their relay is wrong. A checklist that catches the real failures:

  • Kill the relay process after the request is sent but before the response is read, then restart it. The redispatch must carry the same key and the downstream must report one effect, not two.
  • Insert the same outbox row twice from two concurrent transactions. The unique constraint must reject the second insert, not create a duplicate.
  • Return 500 for five attempts, then 200. Confirm attempts increments, next_attempt_at grows, and the row lands in succeeded with a stored downstream_id.
  • Return 400. Confirm the row goes to dead on the first attempt with no further dispatches.
  • Change the intent version mid-retry and confirm the relay produces a different key for the revised intent.

For a full run against a real provider, point a test relay at a sandbox endpoint that records every Idempotency-Key it sees. You want to see repeats of the same key rather than a stream of new ones. Then use the same test to confirm that a model-driven retry, where the model asks for the tool again, reuses the row instead of inserting a new one. That check is the one that catches the orchestrator wiring the key to model output rather than to the durable step.

Once this is running, the relay is a good candidate for consolidation. If your agent pipeline also calls model endpoints for summarization, classification, or embedding of the outbox payloads, Quanta Inference puts text, image, video, audio, and embedding models behind one OpenAI-compatible API key, which removes a second set of credentials and retry policies from the relay box (https://quantaintelligence.ai/inference).

The outbox plus key design also pays off outside agents. The same table handles human-triggered mutations, batch imports, and webhook-driven automations from other systems, because the identity of the operation is derived from data rather than from who pressed the button. The pattern does not care whether the caller was a person or a model; it only needs one durable row per intended effect and one stable key per row.

FAQ

FAQ

Why not just call the downstream API inside the agent's transaction?

Because the downstream call is not part of the database transaction. If you commit and then call, a crash between the two loses the effect; if you call and then commit, a crash before the commit leaves a phantom effect with no record. The outbox row and the state change share one local transaction, so exactly one of the two outcomes is recorded, and the relay handles the rest (the transactional outbox analysis).

Can the agent generate the idempotency key itself?

No. Models are non-deterministic, so a newly sampled value on the second attempt identifies the retry as a different operation and deduplication fails. Generate the key in the orchestration layer from the durable execution step, persist it with the outbox row, and reuse it for every transport retry, model-requested retry, and restart (Agent Tool Call Retries). The same rule holds on the provider side: do not generate a new key on every retry, because the key must stay the same across attempts (monday.com idempotency).

Should a timeout mark the outbox row as failed?

No. A timeout after dispatch does not establish whether the downstream committed the effect, so the outcome is unknown rather than failed. Keep the row pending and redispatch with the same key after a backoff interval; the downstream returns the original result if it already applied the effect (Stripe idempotent requests, Agent Tool Call Retries). Only classify failures as permanent on explicit rejections such as 400, 403, 404, or 410 (Hookdeck retry best practices).

How many retries before a row goes to the dead-letter state?

There is no universal number, but 8 to 10 attempts with exponential backoff and jitter spans between a few hours and a day, depending on your base and cap. Svix spreads 8 attempts over roughly 24 hours, which suits receivers that can be down for a while (Svix retry best practices). Pick the window from the downstream's observable recovery time, and always preserve exhausted rows for inspection and replay rather than dropping them (Dead-Letter Queues for Webhook Reliability).

What happens when two relay workers claim the same row?

Without a claim protocol, both dispatch, and you rely entirely on the downstream to deduplicate, which is a fallback rather than a design. Claim rows with SELECT ... FOR UPDATE SKIP LOCKED so each worker takes a disjoint batch, and set a lease so a worker that dies mid-call releases the row when it expires. The redispatch is safe because the key is unchanged, and the atomic first-writer-wins insert is the only race-free way to claim identity in the first place (Idempotency Patterns, OWASP on race conditions).

Sources

Sources

  1. The Transactional Outbox Pattern: A Rigorous Examination ...
  2. Implementing the Outbox Pattern in .NET for Reliable Messaging
  3. Tool Calling Has a Tax You're Not Seeing | by Duckweave
  4. Handling Race Conditions in Multi-Agent Orchestration
  5. Race Conditions | OWASP Foundation
  6. Agent Tool Call Retries: Idempotency and Recovery
  7. Idempotency Patterns: Building Retry-Safe Distributed Systems
  8. Idempotency
  9. Idempotent requests | Stripe API Reference
  10. Webhook Retry Best Practices for Sending Webhooks
  11. Dead-Letter Queues for Webhook Reliability
  12. Webhook Reliability: Retry and Dead Letter Queues.
  13. Webhook Retry Best Practices | Svix Resources
  14. Mastering Webhook Retry Logic: Strategies and Best ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles