Tools
Plugin Outbox Drain
Deliver messages an app queued on an MCP outbox, on a schedule, with no model in the loop.
Install
npm install &&
Configuration Example
"outbox-drain": {
"sources": {
"myapp": {
"mcpServer": "myapp", // an entry you already have in mcp.servers
"every": "10m", // minimum 30s
"limit": 5, // messages per pass
"deliver": {
"channel": "whatsapp",
"to": "+15550000000",
"account": "assistant" // optional, for multi-account channels
}
}
}
}
README
# 🚰 openclaw-plugin-outbox-drain
**Drains your outbox. No model in the loop.**
[](https://github.com/N30/openclaw-plugin-outbox-drain/actions/workflows/ci.yml)
[](LICENSE)

Your server can't reach your laptop. It's behind NAT, it's asleep half the time,
and punching a hole to it is a worse idea than the problem it solves. So the app
doesn't send — it **records that something should be said**, and something on
your machine dials in and collects it.
```
your app ──writes a row──▶ outbox ◀──claims over MCP── OpenClaw ──▶ your chat
```
That collector doesn't need to think. Claim the rows, send them, mark them sent.
There is no judgement anywhere in that loop — so there's no model in this one.

> **Why this needs a plugin at all:** the ordering is the product. A script on a
> timer can print a message and hope — it has no way to observe whether the chat
> layer accepted it, so it must ack optimistically and lose anything that fails
> after the print. A plugin sends *through the host* and gets an answer back, so
> a row is only ever marked sent once something confirmed it went. That single
> difference is why this isn't a cron job.
> **Status — working, and tested against a fake server.** 21 tests pin every
> ordering guarantee below, and they need no MCP server, no credentials and no
> network. What they can't pin is your server's half of the contract: read
> [The contract your server implements](#-the-contract-your-server-implements)
> before you point this at production.
---
## ✨ What you get
- 🚫 **No model, no tokens, no per-run cost.** Nothing to paraphrase your
message, nothing to decide to say something else, nothing to bill. The same
input produces the same output every time.
- ✅ **Verified delivery.** A message is marked sent only after the chat layer
confirms it went — never before, never on a hope.
- 🔁 **Safe retries by construction.** A failed send acks nothing *and* fails
nothing. The claim lapses on your server and the message comes back. There is
no retry queue here because there doesn't need to be one.
- 🤫 **Silence by default.** An empty outbox produces no message. No "nothing to
report" landing in your chat every ten minutes.
- 🏷️ **Works with your names.** The `claim_outbox` / `mark_sent` / `mark_failed`
vocabulary is a default, not a requirement. Remap the tools, the response
fields, and the limit argument.
- 🔀 **Multi-source.** Drain several outboxes, each on its own schedule, each to
its own chat target.
- 📉 **Honest reporting.** A pass that claimed messages and delivered none
reports failure, not success — because "it completed" and "it worked" are
different questions.
- 🩺 **Preflight at startup.** Missing tools are named when the service starts,
not at 3am when something finally needed to be said.
- 🔑 **No second copy of your credentials.** It points at an entry you already
have in `mcp.servers` and reuses its URL, headers and auth.
- ⌨️ **Manual drain.** `openclaw outbox` runs a pass now and tells you exactly
what happened.
## 🧠 Why "no model" is the headline
This started as a scheduled agent turn. It failed three ways at once, and none
of them were the model's fault:
**~50k tokens per run.** The prompt was 150 words. The rest was *tool
definitions* — every MCP schema plus the host's own toolset, serialised and
resent every ten minutes, forever.
**The fallback chain couldn't absorb a prompt that size.** A free-tier provider
capped at 8,000 tokens per minute returns `413` on a 50k request. So when the
primary model timed out, there was nowhere to fail over to, and the whole run
died.
**A timed-out MCP connection silently removes the tools.** The model then
answers, quite correctly from where it's sitting, *"I don't have that tool —
could you clarify?"* — and the delivery layer forwards that to the owner as a
question. From the outside it looks like the assistant has lost its mind. It
hasn't; it's been handed an empty toolbox and asked to use a hammer.
Those are the costs of putting a model where one wasn't needed. Removing it
removed all three at once, along with the per-run cost and the latency.
## 🎯 Perfect for
Backup and job-completion notices · nightly report summaries · "your export is
ready" · alerting from a Laravel app that has no route to your phone · approval
prompts raised by background work · anything your server already knows and you'd
rather be told than go looking for.
## 🐛 What it tackles
Each of these is a bug that actually happened, and is now a test.
### Marking things sent that were never sent
The obvious implementation claims a batch, prints it, and marks it sent. If
delivery fails after the print, that message is gone — acked on the server,
never seen by anyone.
This one sends through the host and **waits for the answer** before acking. A
script on a timer structurally cannot do this, which is the entire reason this
is a plugin.
### Two drains saying the same thing twice
A schedule fires while someone runs a manual drain. Both claim the same rows;
you're told your 3pm is starting, twice. The fix belongs on your server — see
[the contract](#-the-contract-your-server-implements) — but this plugin is built
to assume it's there and to make duplicates loud when they slip through.
### `status: ok` on a run that did nothing
A drain returned "ok" forty consecutive times while delivering nothing at all,
and was read as forty healthy runs. **"It completed" and "it worked" are
different questions.** Every pass reports `ok: false` if it claimed messages and
delivered none, and the manual command prints the counts.
### A 403 that looks like a revoked token
Some edges — Laravel Cloud among them — reject a default runtime `User-Agent`
*before the request reaches your app*. Your token is fine. Everything is fine.
You will spend an afternoon rotating credentials anyway. This plugin sends a
real UA, and if it ever sees a 403 it says so in the error.
### `isError: true` arriving with HTTP 200
MCP tool-level refusals ("that token is not scoped for this") are a **successful
HTTP response** carrying a failure. Treat that as "no messages" and your drain
reports success forever while delivering nothing. Here it's an error.
### Giving up on something retryable
`mark_failed` means *give up permanently*. That's right for "no such chat" and
wrong for a timeout. This plugin only ever calls it when a sender can articulate
why — which, since it never guesses, means it doesn't call it at all. Transport
failures are handled by silence plus an expiring claim.
### A row with no id
A message that arrives without an id can never be acked, so draining it would
guarantee a duplicate on the next pass. It's skipped. Skipping is the lesser
wrong.
## 🚧 Deliberately not in here
- **No dedupe.** Atomic claiming is your server's job, and doing it in two
places would just hide when one of them is broken.
- **No retry queue.** Expiring claims already are the retry. A second mechanism
would only be a way for the two to disagree.
- **No credential storage.** It reuses an `mcp.servers` entry rather than asking
you to paste your token somewhere new.
- **No `mark_failed` on transport errors.** Permanent means permanent; a timeout
isn't.
---
## 📦 Install
Requires **OpenClaw ≥ 2026.5.17** and **Node ≥ 22**.
```sh
openclaw plugins install github:N30/openclaw-plugin-outbox-drain
openclaw plugins enable outbox-drain
```
Or from a clone:
```sh
git clone https://github.com/N30/openclaw-plugin-outbox-drain
cd openclaw-plugin-outbox-drain && npm install && npm run build
openclaw plugins install .
```
Then restart the gateway so it loads.
## ⚙️ Configure
```jsonc
"outbox-drain": {
"sources": {
"myapp": {
"mcpServer": "myapp", // an entry you already have in mcp.servers
"every": "10m", // minimum 30s
"limit": 5, // messages per pass
"deliver": {
"channel": "whatsapp",
"to": "+15550000000",
"account": "assistant" // optional, for multi-account channels
}
}
}
}
```
The MCP server itself is configured once in your host's `mcp.servers` — this
plugin points at it by name and reuses its URL, headers and auth. It never asks
you for a second copy of your credentials.
If your server speaks a different vocabulary:
```jsonc
"tools": { "claim": "take_jobs", "ack": "done", "fail": "give_up" },
"fields": { "list": "items", "id": "uid", "text": "body" },
"limitArg": "count"
```
## 🧑💻 Use it
```sh
openclaw outbox # drain every source now
openclaw outbox myapp # drain one
```
Both print what happened:
```
myapp: claimed 3, delivered 3, acked 3
myapp: nothing queued
```
Scheduled passes log nothing at all when the outbox was empty — they write a
line only when they claimed something, and a warning when a pass wasn't `ok`.
## 📜 The contract your server implements
Three tools. Names are defaults; the **shape** is the contract.
| Role | Default | Must do |
| --- | --- | --- |
| claim | `claim_outbox` | Take up to `limit` messages, mark them claimed so a concurrent drain can't take the same ones, return `{ messages: [{ id, summary }] }` |
| ack | `mark_sent` | Resolve one message by `id`. Accepts an optional `remote_ref`. |
| fail | `mark_failed` | Give up on one message permanently, with a reason. |
Two properties this plugin **relies on you for**:
**Claiming must be atomic.
... (truncated)
tools
Comments
Sign in to leave a comment