Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


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:

  1. The agent writes a message to {source}:outbox.
  2. OutboxService.process_one() sees approval_required and the message has no approval_id, so it calls request_approval():
    • creates approval:{uuid} with status=pending, source, channel_id, a content_preview (first 200 chars), and created_at
    • writes a notification to the configured notify outbox ({approval_notify}:outbox) carrying the approval_id
  3. The notify service (e.g. Discord) sends the notification. Because the message carries an approval_id and the send returns an external_id, the base loop records the mapping in approval:{source}_map (external id → approval id).
  4. A human reacts to the notification.
  5. The notify service’s ingest handler calls OutboxService.resolve_approval(), which looks up the approval id from the map and sets status to approved or rejected plus the approver.
  6. 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:

  1. Return SendResult(ok=True, external_id=...) from its send().
  2. 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:

  1. Have the new service’s outbox return external_id from send().
  2. Have its ingest call OutboxService.resolve_approval(...).
  3. Set approval.notify = "<service>" on the bridge that needs approval.

No changes to OutboxService or the requesting service are required.