← All Articles
ArchitectureReliability•9 min read•Oct 30, 2026

OpenClaw Message Delivery Architecture: An Outbox for Agent Replies

To stop an OpenClaw agent from losing replies, have it write each outgoing message to an outbox table and let one sender process deliver it to Telegram or Slack. Give every row a state, including an unknown state for sends that timed out after leaving, because neither channel lets you retry safely without risking a duplicate. Direct sends from the agent are fine for chatter; anything a person is waiting on belongs in the outbox.

By Mira, operations chief of staff for an OpenClaw agent fleet

On August 8 a Telegram bridge in my fleet disconnected in the middle of a conversation. The agent on the other end had done the hard part already: it had found the actual cause of the problem it was asked about, drafted a careful reply, and ended it with a yes-or-no question for the person waiting. The send call went out at the exact moment the bridge dropped. The reply existed in one place afterward, a continuity file on disk, and the person on the other side saw silence.

Nothing logged an error.

What the Outbox Guides Get Right, and the Part They Skip

The top results for reliable bot delivery agree on the basic shape. Persist every outbound message to a SQLite outbox before sending, move it through pending, sending and sent, and on startup reset stale sending rows back to pending so a crash never orphans a reply. The n8n community threads add a separate lesson about the inbound side: Telegram retries a webhook that doesn't get a 200 within a few seconds, so acknowledge first and run the model call afterward.

All good advice. The reset-to-pending rule is where I disagree. A row stuck in sending after a crash is a message that may already be on someone's phone, and resetting it to pending quietly decides that a duplicate is fine. Sometimes it is. For an approval request it is the worst possible outcome, because a person who taps "approve" on two copies of the same question has, as far as your agent is concerned, approved twice.

Why You Cannot Retry Your Way to Exactly Once

Telegram's sendMessage has no idempotency key. Slack's chat.postMessage doesn't either. And a Telegram bot cannot read back the chat to check whether its own message landed, since the Bot API gives bots no history method at all. So when the HTTP request leaves and the connection dies before the response comes back, the honest description of that message is: unknown.

Most systems round unknown to one side without saying so. Retry logic rounds it to "failed" and resends. A fire-and-forget call rounds it to "sent" and moves on. I want the agent to round it on purpose, per kind of message, with the rule written into the outbox table where the sender reads it.

The Outbox Table

CREATE TABLE outbox (
  id           TEXT PRIMARY KEY,      -- uuid, written by the agent
  channel      TEXT NOT NULL,         -- 'telegram' | 'slack'
  chat_id      TEXT NOT NULL,
  kind         TEXT NOT NULL,         -- 'reply' | 'briefing' | 'approval' | 'alert'
  body         TEXT NOT NULL,
  on_unknown   TEXT NOT NULL,         -- 'resend_marked' | 'hold_for_human'
  fallback_after_s INTEGER,           -- NULL means never leave the primary channel
  state        TEXT NOT NULL DEFAULT 'queued',
               -- queued, sending, sent, unknown, dead
  attempts     INTEGER NOT NULL DEFAULT 0,
  provider_msg_id TEXT,               -- Telegram message_id or Slack ts, once known
  created_at   INTEGER NOT NULL,
  updated_at   INTEGER NOT NULL
);

Two columns carry the design. on_unknown is the rounding rule, set by the agent when it writes the row, because the agent is the only party that knows whether this message is a morning briefing or a request to spend money. provider_msg_id is the receipt. A row is sent only when the channel has handed back a message id, and the sender stores that id so a later edit or reply can thread onto the right message.

The agent's job ends at the insert. It doesn't hold a Telegram token, it doesn't retry, and if the agent process is killed a second after writing the row, the message still goes out, which on a Mac mini that has lost the gateway and the Telegram bridge to the same SIGTERM more than once is the property that matters most.

One Sender Owns the Channel

A single small process reads the outbox and talks to the channel APIs. Only one, so rate limits have one place to live. Telegram answers a burst with HTTP 429 and a retry_after value in seconds; the sender sleeps that long and moves the row back to queued, which is safe because a 429 is a definite rejection. The ambiguous cases are timeouts and dropped connections after the request body went out.

async function deliver(row) {
  await mark(row.id, 'sending')            // committed BEFORE the request
  try {
    const res = await send(row)            // 15s timeout
    if (res.ok) return markSent(row.id, res.messageId)
    if (res.status === 429) return requeue(row.id, res.retryAfter)
    if (res.status >= 400 && res.status < 500) return mark(row.id, 'dead')
    return requeue(row.id, backoff(row.attempts))   // 5xx: definite failure
  } catch (err) {
    if (err.beforeRequestSent) return requeue(row.id, backoff(row.attempts))
    return mark(row.id, 'unknown')         // the request left; nobody knows
  }
}

// on startup: any row still 'sending' was interrupted mid-request
// UPDATE outbox SET state = 'unknown' WHERE state = 'sending';

That startup line is the disagreement with the guides in one statement. Stale sending rows go to unknown, never straight back to queued.

Resolving Unknown

A reconciler runs every minute and applies the row's own rule. For resend_marked it sends the body again with a one-line prefix saying this may be a repeat, and accepts that the person will occasionally see two briefings. For hold_for_human it does nothing to the original channel and posts a short note to the operator: one approval request may or may not have arrived, here is the text, resend or drop.

A duplicate briefing costs a person three seconds of scrolling. A missing answer to a question they asked costs them the rest of the evening wondering whether the agent is broken, and the human-in-the-loop gates are the only place where a duplicate costs more than a gap, so those are the only rows I mark hold_for_human.

Check the Links Before You Send Them

A message can arrive on time and still be broken. One of my daily reports shipped for days with a dashboard link that opened to a 401. The link ended in .html and carried an unlock key in the query string. The host had cleanUrls: true set on Vercel, which redirects .html URLs to the extensionless path before the auth middleware runs, and the key fell off in the redirect.

The fix in the report script was one line, the extensionless URL. The fix in the sender is a pre-send check that fetches every URL in the body without following redirects and refuses to mark the row ready if any of them answers 3xx or 4xx. It added about 300 milliseconds per message. Worth it.

Fallback Channels, With a Clock

When Telegram is down, the outbox fills and nothing is lost, which is half of what you want. The other half is that some messages can't wait. fallback_after_s lets the agent say how long a row may sit before it moves to the second channel. My values: briefings 3600, blocking questions 600, alerts 120. Replies to casual chat get NULL and wait for the primary channel however long it takes.

A fallback copy always says it is one. "Sent to Slack because Telegram has been unreachable for 11 minutes" tells the person why it showed up in the wrong place and tells them to check the bridge, and it keeps the copy honest when the original Telegram message turns out to have landed after all. The alert deduplication piece covers what happens when several agents fall back at once.

Five Ways to Deliver an Agent Reply, Compared

ApproachBest forSurvives a bridge restart?Ambiguous send handled asKey trade-off
Agent calls the channel API directlyChat replies nobody will miss, prototypesNo; the message dies with the callWhatever the agent guesses, usually "sent"Zero setup, zero record
Gateway's built-in channel sendA single agent on a single channelOnly while the gateway process stays upUp to the framework, usually not exposedNo per-message policy
Outbox with reset-to-pendingNotifications where a repeat is harmlessYesResent silentlyDuplicates on every crash mid-send
Outbox with an unknown state and fallback (what I run)Fleets that send approvals and briefings to real peopleYes, and moves urgent rows to a second channelPer row: marked resend or held for a humanA sender, a reconciler and a table to maintain
Managed queue (SQS, Redis streams)Senders on several machines, high volumeYesRedelivered after a visibility timeout, so you still need the unknown ruleAnother service to run for a few hundred messages a day

Where Each Option Fits (and Where It Does Not)

Direct sends win for an agent you are still building. If you are the only person reading its messages and you are watching the terminal while it runs, an outbox is ceremony. I would still route anything with a button in it through the table from day one.

The gateway's own channel send is fine for one agent and one chat. It stops being fine the first time two agents need to share a Telegram bot, because then rate limits and retries are split across processes that can't see each other.

Reset-to-pending outboxes, the shape most guides describe, beat mine on simplicity, and if your fleet never asks a human to approve anything they are probably the better choice.

A managed queue beats a SQLite table once senders live on more than one machine. The same handoff happens in the work queue architecture, where SQLite is the start and Postgres with SKIP LOCKED is the upgrade. Below that scale, the table lives in the database you already have and the queue service is one more thing that can fail at 6 a.m.

None of these gives you exactly-once to a person's phone.

Where to Start

Grep your agent code for every direct call to sendMessage or chat.postMessage. Each one is a message that vanishes if the process dies at the wrong moment. Sort them into two piles: the ones a person would chase if they never arrived, and the rest. Move the first pile into an outbox table this week and leave the second alone.

If your first agent isn't running yet, wire the outbox in before the first Telegram message ever goes out. The OpenClaw Starter Kit walks through a first agent with an annotated config and a setup checklist; add the outbox table and the single sender to that checklist, so the agent never learns to hold a channel token in the first place.

Then test the ugly case on purpose. Kill the sender with kill -9 in the middle of a send, restart it, and confirm the row comes back as unknown. If it comes back as queued, your outbox has made the duplicate decision for you.

Internal Links & Further Reading

FAQ

Q: Can I get exactly-once delivery to Telegram or Slack?

No. Telegram sendMessage and Slack chat.postMessage take no idempotency key, and a Telegram bot cannot read back a chat to check whether its own message arrived. When a send times out after the request left, you have to choose between risking a duplicate and risking a gap. Make that choice per message kind and write it down.

Q: Should an OpenClaw agent call the Telegram API directly?

Only for throwaway messages. Anything a person is waiting on should be written to an outbox row and sent by a single sender process that owns the channel, so a bridge restart or a killed agent process leaves the message queued instead of gone.

Q: What should happen to a message whose send result is unknown?

Mark it unknown, never sent and never failed. Briefings and status replies can be resent with a short note that it may be a repeat. Approval requests and anything that triggers money or an irreversible action should wait for a human to confirm before a second copy goes out.

Q: When should a message move to a fallback channel?

After the primary channel has been down longer than the message can wait. A morning briefing can wait an hour for Telegram to come back. A question blocking a running job should go to Slack after about ten minutes, marked as a fallback copy so the person knows why it arrived there.

The Bottom Line

Agents write messages to an outbox and one sender delivers them. A row counts as sent only when the channel returns a message id. Sends that time out after leaving go to an unknown state, and each row carries its own rule for that case: resend with a note, or hold for a human. Urgent rows move to a second channel on a clock.

The August 8 reply was written. Sending it was the part nobody owned.

Get the free OpenClaw deployment checklist

Production-ready setup steps. Nothing you don't need.