Outbox Operator CLI

Inspect, retry, and purge individual entries in the durable outbound queue

Inspect why an agent reply is stuck, retry it after a channel recovers, or drop a genuinely-dead one — one entry at a time.

Diagram

Outbound Queue Operations

Failed

🤖 Agent Reply

💾 Outbox

📤 Delivered?

🔍 outbox list

♻️ outbox retry

🗑️ outbox purge

Quick Start

  1. Run an agent bot (writes to the outbox automatically)

    A running gateway bot persists every outbound reply to ~/.praisonai/state/gateway_outbox.sqlite — no code change needed.

    bash
    praisonai gateway start --config gateway.yaml
  2. Find stuck replies
    bash
    praisonai gateway outbox list --status failed
  3. Retry one after the channel recovers
    bash
    praisonai gateway outbox retry slack:C0abc:msg-d4e5:18
  4. Or drop a genuinely-dead one
    bash
    praisonai gateway outbox purge telegram:12345:msg-a1b2:17

User interaction flow

An operator moves a stuck reply from failed to delivered without restarting the gateway.

Diagram
UserGateway drain💾 Outboxgateway outboxOperatorUserGateway drain💾 Outboxgateway outboxOperatorchannel recoversopen as read_only=True — no crash recovery runsgateway status –deep“Outbox: praisonai gateway outbox list …”outbox list –status failedkey=slack:C0abc:msg-d4e5:18 (429 rate limited)outbox retry slack:C0abc:msg-d4e5:18status → pending, attempts → 0next drain claims entrydelivers reply

Commands

Four subcommands mounted under praisonai gateway outbox.

CommandPurposeKey Flags
outbox listEnumerate entries (newest first)--path/-p, --status/-s, --target/-t, --limit/-n (default 20)
outbox statsPer-status count map--path/-p
outbox retry <key>Requeue one failed/permanent_failure entry--path/-p
outbox purge [<key>]Delete one entry, or all with --all--path/-p, --all, --yes/-y
bash
$ praisonai gateway outbox list --status failed
Outbox /Users/…/state/gateway_outbox.sqlite — 2 entries (showing 2):
  key=telegram:12345:msg-a1b2:17
      target=telegram:12345 status=permanent_failure attempts=5 age=3200s error=chat not found
  key=slack:C0abc:msg-d4e5:18
      target=slack:C0abc status=failed attempts=2 age=45s error=429 rate limited

$ praisonai gateway outbox retry slack:C0abc:msg-d4e5:18
requeued 1 entry

outbox stats gives an at-a-glance breakdown instead of a single counter:

bash
$ praisonai gateway outbox stats
Outbox /Users/…/state/gateway_outbox.sqlite:
  failed             1
  pending            3
  permanent_failure  2

Clear everything in one shot (asks for confirmation unless --yes):

bash
praisonai gateway outbox purge --all --yes

Where the outbox lives

outbox resolves the SQLite path in a fixed precedence order.

Diagram

set

unset

set

unset

–path / -p flag

✅ Use this path

PRAISONAI_OUTBOX_PATH env

~/.praisonai/state/gateway_outbox.sqlite

PrecedenceSourceValue
1--path / -pExplicit path you pass
2PRAISONAI_OUTBOX_PATHEnvironment variable
3Default~/.praisonai/state/gateway_outbox.sqlite — the canonical outbox GatewayServer.scheduled_outbox writes

Entry keys

outbox list prints a full tracking key per entry in the form target:idempotency_key:id, e.g. slack:C0abc:msg-d4e5:18. outbox retry and outbox purge consume that key verbatim.

Keys are always printed un-truncated so operators can copy them directly. The key is matched in full against the stored row, so a stale key from another outbox — or a mistyped one — can never mutate the wrong message.


Safe to run alongside a live gateway

All four subcommands — outbox list, outbox stats, outbox retry, outbox purge — open the SQLite store with read_only=True.

read_only=True only skips the sending → recovered crash-recovery migration. Targeted ops still work: retry only touches failed/permanent_failure rows, and purge <key> refuses sending rows. purge --all is an explicit, confirmed operator action and still deletes every row, including sending ones.

Fixed in PraisonAI PR #5954 (issue #5953) — before the fix, retry and purge opened the store read-write and could duplicate a live gateway’s in-flight sends.


Python API

The CLI mirrors four OutboundQueue methods. Wire them from Python directly.

python
from praisonai_bot.bots import OutboundQueue

# Safe to run alongside a live gateway — no crash recovery runs
outbox = OutboundQueue(
    path="~/.praisonai/state/gateway_outbox.sqlite",
    read_only=True,
)

# Inspection
entries = outbox.list(status="failed", target="slack:C0abc", limit=50)
counts = outbox.stats()  # e.g. {"pending": 3, "permanent_failure": 2}

# Targeted ops still work under read_only=True. retry only touches
# failed/permanent_failure rows; purge_entry refuses to delete a 'sending' row.
await outbox.retry("slack:C0abc:msg-d4e5:18")
outbox.purge_entry("slack:C0abc:msg-d4e5:18")

read_only=True only skips the sending → recovered migration — targeted writes still succeed. Runtime drainers (the gateway itself, DurableAdapterMixin adapters) still open the store read-write and run crash recovery on open; this guidance is for operator code running alongside a live gateway.

list(*, status=None, target=None, limit=100)

ParameterTypeDefaultDescription
statusstrNoneFilter by status (see values below)
targetstrNoneExact target match, e.g. telegram:12345
limitint100Max entries returned

Returns — a list of OutboundEntry (newest first). Never mutates state.

Status filter values: pending, sending, recovered, sent, failed, permanent_failure.

stats() -> dict[str, int]

Returns — a per-status count map; only statuses with at least one entry are present.

retry(key) -> bool (async)

Resets a failed/permanent_failure entry back to pending and clears its attempts so the next drain() re-dispatches it. Returns True if requeued, False if the key is unknown, already sent, in-flight (sending), or crash-recovered.

purge_entry(key) -> bool

Deletes a single entry by its full tracking key. Returns True if removed, False on unknown/mismatched key or an in-flight sending entry.


Best Practices

Copy full keys verbatim

outbox list prints keys un-truncated on their own line. retry and purge match the full target:idempotency_key:id — a partial or edited key is rejected, never guessed.

retry refuses a recovered entry — on purpose

A crash-recovered entry was in-flight when the process last crashed, so its delivery outcome is unknown. The next drain() already reconciles or re-sends with a “possible duplicate” annotator. Flipping it to pending manually strips that safeguard and risks an unlabelled duplicate.

Never purge a sending entry

purge_entry refuses an in-flight sending row. The gateway may have already handed the payload to the channel API and be awaiting mark_sent/mark_failed. Deleting the row makes mark_sent a no-op and the caller report a spurious failure despite delivery. Wait until it transitions.

Don't construct a writable OutboundQueue next to a live gateway

Opening OutboundQueue(path=...) without read_only=True while the gateway is running flips every in-flight sending row to recovered — the gateway’s next drain then re-dispatches those rows, producing duplicate replies. Use read_only=True for any operator-side script that touches a live outbox; retry and purge_entry still succeed under it.

Start from gateway status –deep

praisonai gateway status --deep now prints an outbox hint pointing at outbox list, so the diagnostic surface leads straight to the operator commands.


Related