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 its outbox —
{agent}:{source}:outboxunder the per-agent ACL (the flat{source}:outboxon a shared Redis). OutboxService.process_one()seesapproval_requiredand the message has noapproval_id, so it parks the entry (_resume_or_start_approval):- creates
approval:{uuid}withstatus=pending,source,channel_id, acontent_preview(first 200 chars),created_atandexpires_at - writes a notification to the configured notify outbox
(
{approval_notify}:outbox) carrying theapproval_id - returns without acking, so the rest of the stream keeps moving
- 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, if the record is stillpendingand inside its window, setsstatustoapprovedorrejectedplus theapprover. A late answer — a record already decided, pastexpires_at, or already settled and deleted — is ignored; the map entry is dropped either way. - The original outbox service checks its parked entries once per loop
(
poll_awaiting, viacheck_approval, which reads the deadline before the decision); it sends the message onapproved, or acks it and tells the agent on its inbox that it wasrejectedorexpired.
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
expires_at: unix timestamp; past it the record reads as expired
approver: who resolved it (set on resolution)
}
pending --+-- approve --> approved --> send
+-- reject --> rejected --> drop, notify the agent
+-- timeout --> expired --> drop, notify the agent
expired is never written: it is what check_approval answers for a record
past expires_at, whatever its status field says. poll_awaiting deletes
the approval hash when it settles the entry.
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
The park-and-poll flow (process_one(), poll_awaiting()), the static
resolve_approval(), and the auto-tracking of the map 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.
Two bridges use this protocol for something other than a message. rebuild
gates a system rebuild, and effects gates a declared action on the world
(a git push, for instance) — see Gated Effects. For effects,
approval.required is read-only true: there is no unapproved mode, and the
approval is bound to a specific commit sha rather than to the request text,
so what the operator approved is what runs, byte for byte.
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.