← Back to Plugins
Tools

Plugin Outbox Drain

N30 By N30 👁 93 views ▲ 0 votes

Deliver messages an app queued on an MCP outbox, on a schedule, with no model in the loop.

Homepage GitHub

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.**

[![CI](https://github.com/N30/openclaw-plugin-outbox-drain/actions/workflows/ci.yml/badge.svg)](https://github.com/N30/openclaw-plugin-outbox-drain/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

![An OpenClaw plugin that claims queued messages over MCP and says them on your chat channel, acking only once delivery is confirmed](https://github.com/all1web/plugin-assets/raw/main/outbox-drain/hero.png)

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.

![App queues a row, OpenClaw claims it on a schedule over MCP, says it in your chat, and only then acks it](https://github.com/all1web/plugin-assets/raw/main/outbox-drain/flow.png)

> **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

Loading comments...