title: Outbox Approval Protocol tags: [pubsub, approval, discord, email]
Outbox Approval Protocol
Outbox services can hold an outbound message for human approval before sending it. The agent never sends directly; it writes to an outbox stream, and a broker service decides whether to forward the message, optionally gating it behind a human reaction.
Source: pkgs/bridge/base.py (OutboxService), pkgs/bridge/ingest.py
(IngestService), with per-platform implementations in pkgs/discord/ and
pkgs/email/.
See also Security Model.
Transport
Messages move over Redis Streams. Each bridge {name} has an inbox stream
({name}:inbox) and an outbox stream ({name}:outbox). OutboxService reads
its outbox via a consumer group ({name}-outbox / worker {name}-worker) and
acknowledges each entry after handling it. Approval state lives in plain Redis
hashes so any service can poll it.
Flow
When approval_required is set, an outbound message is held and routed through
a notification channel:
- The agent writes a message to
{source}:outbox. OutboxService.process_one()seesapproval_requiredand the message has noapproval_id, so it callsrequest_approval():- creates
approval:{uuid}withstatus=pending,source,channel_id, acontent_preview(first 200 chars), andcreated_at - writes a notification to the configured notify outbox
(
{approval_notify}:outbox) carrying theapproval_id
- creates
- The notify service (e.g. Discord) sends the notification. Because the message
carries an
approval_idand the send returns anexternal_id, the base loop records the mapping inapproval:{source}_map(external id → approval id). - A human reacts to the notification.
- The notify service’s ingest handler calls
OutboxService.resolve_approval(), which looks up the approval id from the map and setsstatustoapprovedorrejectedplus theapprover. - The original outbox service is polling in
wait_for_approval(); it sees the resolved status and either sends the message or drops it, then acks.
State machine
approval:{uuid} = {
status: pending | approved | rejected | expired
source: email | discord | ...
channel_id: destination (email address, channel id, ...)
content_preview: first 200 chars of the message body
created_at: unix timestamp
approver: who resolved it (set on resolution)
}
pending --+-- approve --> approved --> send
+-- reject --> rejected --> drop
+-- timeout --> expired --> drop
On timeout wait_for_approval() returns false and deletes the approval hash.
Redis keys
approval:{uuid} # HASH — approval state
approval:{source}_map # HASH — external message id -> approval id
The requesting service writes and polls approval:{uuid}. The notify service
owns approval:{source}_map. Bridges share only the key format — Discord and
email do not know about each other.
Implementation
request_approval(), wait_for_approval(), the static resolve_approval(),
and the auto-tracking in process_one() all live in the shared
OutboxService. A per-platform bridge only needs to:
- Return
SendResult(ok=True, external_id=...)from itssend(). - Call
OutboxService.resolve_approval(pubsub, source, external_id, status, approver)from its ingest handler when a reaction or reply arrives (IngestService.try_resolve_approval()wraps this).
systemd services
Each bridge {name} is run as three systemd services: cowboy-{name}-ingest,
cowboy-{name}-outbox, and cowboy-{name}-ping.
Configuration
Approval is configured per bridge under
services.cowboy.bridges.<name>.approval (and equivalently on
services.cowboy.pubsub.sources.<name>.approval):
services.cowboy.bridges.discord.approval = {
required = false; # hold outbound messages for manual approval
notify = "discord"; # which outbox to send approval notifications to
notify_channel = ""; # channel id within that outbox
timeout = 3600; # auto-reject after N seconds (0 = no timeout)
};
If required is true, notify_channel must be set. The Redis ACL enforcement
option keeps the agent restricted to stream commands so it cannot touch the
approval hashes directly.
Adding a new approval channel
To approve via something other than Discord:
- Have the new service’s outbox return
external_idfromsend(). - Have its ingest call
OutboxService.resolve_approval(...). - Set
approval.notify = "<service>"on the bridge that needs approval.
No changes to OutboxService or the requesting service are required.