Skip to main content
reference

outbound_policy — the approval outbox

outbound_policy is the personality field (a directory of files that decides an agent's tools, memory, and model) that turns an agent-initiated send_message into a queued proposal instead of a send. A human approves one exact revision of one text to one destination from one bot, and only then does it go out.

The queue is durable SQLite at ~/.ethos/outbox.db. The agent's tool call returns Queued for approval (obx_…, revision 1). NOT sent. and the turn continues.

Source​

ConcernWhere it lives
Field typepackages/types/src/personality.ts — OutboundPolicyConfig, PersonalityConfig.outbound_policy
Parse + validationextensions/personalities/src/index.ts — buildOutboundPolicy, parseOutboundChannels, OUTBOUND_POLICY_PLATFORMS
The gateextensions/tools-messaging/src/index.ts — gateSend inside executeSendMessage
Gate constructionpackages/wiring/src/compose-tools.ts — createOutboxGate
Store, lifecycle, hashextensions/outbox/src/ — SQLiteOutboxStore, OutboxService, computeContentHash
Sender, reviewer, dispatcher, cardsapps/ethos/src/lib/outbox-wiring.ts
Deliveryextensions/gateway/src/index.ts — Gateway.deliverPublication
Terminal approval surfaceapps/ethos/src/commands/outbox.ts — ethos outbox list | show | approve | reject
Web approval surfacepackages/web-contracts/src/router.ts (outbox.*), apps/web-api/src/services/outbox.service.ts, apps/web/src/pages/Outbox.tsx

Fields​

Written in ~/.ethos/personalities/<id>/config.yaml as flat dotted keys. The schema is frozen and outbound_policy counts as one field (personality governance).

outbound_policy.approve_before_send: true
outbound_policy.channels: telegram slack
outbound_policy.approver_personality: brand-editor
KeyTypeDefaultDescription
approve_before_sendbooleanabsentTurns the gate on. Only the literal string true enables it — buildOutboundPolicy compares approve === 'true', so yes, True and 1 all parse as false. When the key is absent the whole block is skipped and nothing is parsed.
channelswhitespace-separated platform namesabsentWhich platforms the gate covers. Absent means every platform. An empty list is read as every platform too — createOutboxGate treats channels.length === 0 the same as undefined, because the reading that gates more is the one a mis-parsed list survives. An unknown name fails the personality load.
approver_personalitypersonality idabsentAn advisory reviewer that reads the draft before the human does. It can neither approve nor block — see Reviewer.

Each sub-key is independent of the others in parsing but not in effect: approve_before_send: false ignores channels at runtime, while a bad name inside channels still fails the load.

Platform vocabulary​

channels may name these five, and nothing else — OUTBOUND_POLICY_PLATFORMS:

NameGate fires on a send_message to that platformA bot roster exists to publish from
telegramYesYes — telegram.bots
slackYesYes — slack.apps
whatsappYesYes — whatsapp entries carrying a bind
discordYesNo — see the note below
emailYesNo — see the note below

An unknown platform name fails the personality load with Invalid outbound_policy.channels: "<name>". Expected one of: slack, telegram, discord, whatsapp, email. — thrown by parseOutboundChannels. A typo that matched nothing would leave the platform ungated while the config read as if it were gated, so the failure is deliberate and it does not depend on approve_before_send being true.

The list is a deliberate copy of SEND_MESSAGE_PLATFORMS in extensions/tools-messaging/src/index.ts (@ethosagent/personalities must not import a sibling extension). The two are pinned equal by packages/wiring/src/__tests__/outbound-policy-platforms.test.ts.

discord and email gate but cannot publish. buildBotSpeakers (apps/ethos/src/commands/gateway.ts) builds its bot roster from Telegram bots, Slack apps and bound WhatsApp entries only. For a gated personality, a send_message to discord or email therefore resolves zero sender candidates and resolveSender refuses the proposal outright: nothing is queued and nothing is sent. The tool returns the refusal for the agent to read.

What the gate covers​

PathGated?Enforced by
Agent-initiated send_message to a third partyYesgateSend returns the queued result before opts.send is reached (extensions/tools-messaging/src/index.ts)
send_message to the turn's own chat (${platform}:${target} equals ctx.origin)No — exemptgateSend; that destination is the conversation, where an ordinary reply lands anyway
send_message to the operator's chat (channel_filter.<platform>.ownerUserId)No — exemptgateSend via OutboxGate.ownerTarget
A target outside the operator allowlistRefused, never queuedThe getAllowedTargets check runs before gateSend in executeSendMessage, so approval can never widen what the operator allowed
watcher_create with a deliver target that is neither exempt destinationRefused, pointed at wakerefuseForeignDeliver in extensions/tools-watchers/src/index.ts
A stored watcher's deliver, on every change, to a target that is neither exempt destinationWithheld, not sent. The reason, pointing at wake, is stored on the watcher (deliveryWithheld), logged, and shown by watcher_list; a wake on the same watcher still firesWatcherManager.dispatchChange in extensions/watchers/src/index.ts, through the same isForeignDeliverForGatedOwner predicate the creation refusal uses
Conversational replies, cron delivery to job.origin, goal notes, owner notices, channel digests, team/mesh dispatchNoNone of them publishes agent-drafted text to a third party; they never call the gate
MCP tools that post, and a2a_sendNo — not covered at allNothing. Stated on every character sheet by publishingLine (extensions/personalities/src/character-sheet.ts): not covered: MCP tools, a2a_send
Dry runs and replaysNever reach the gateexecuteParallel returns synthesizeDryRunResult without calling execute (packages/core/src/tool-registry.ts)

Where the gate is wired​

A turn reaches a channel through two seams. send_message sends only after the host calls setMessagingSend; until then it returns wiring's default gatewaySendRef error, Gateway not active — send_message requires gateway mode (packages/wiring/src/compose-tools.ts). A watcher's deliver needs the watcher tools, which are registered only when the host constructs a WatcherManager. Every command that holds either seam builds the outbox from apps/ethos/src/lib/outbox-wiring.ts:

CommandPath to a channelOutbox
ethos gateway start, ethos bootAdapters in the processThe whole outbox, createOutboxRuntime: gate, reviewer, Telegram cards, and the dispatcher that delivers
ethos serveNo adapters. Its watcher tools store deliver targets in ~/.ethos/watchers/watchers.json, and a gateway on the machine sends from themThe proposal side, createOutboxProposalSide: gate and reviewer, no dispatcher, no card. A send_message it queues is delivered by a gateway's dispatcher once approved
ethos chat, cron, mcp, batch, eval, acp, benchNone. Nothing calls setMessagingSend and no watcher tools are registered, so send_message fails with Gateway not active for every personalityNone, because there is nothing to gate

apps/ethos/src/__tests__/outbox-gate-live.test.ts pins every row: it checks the wiring in the first two rows, and fails if either seam appears in a command that builds no outbox.

A gate that throws refuses the send: gateSend converts any error from gates, ownerTarget or propose into an execution_failed tool result reading "Nothing was sent." It never falls through to the adapter.

Lifecycle​

propose awaiting_review awaiting_approval approved rejected expired sending sent unconfirmed failed edit (revision+1, prior approval void) receipt | stale >10m: "unavailable" approve(rev,hash) revoke (no approver_personality) awaiting_* older than 7d reject(reason) approved not claimed within 24h claim pre-send refusal retry (same rev)
The outbound publication lifecycle: propose flows through review and approval to sending and a terminal state, with edit, revoke, expiry, and retry transitions preserved.
StateMeaning
awaiting_reviewThe advisory reviewer is running. No human has seen it.
awaiting_approvalIn front of a human, with the reviewer's receipt if there is one.
approvedA human approved this revision. The gateway dispatcher has not claimed it yet, so it can still be revoked.
sendingClaimed by a dispatcher and in flight.
sentThe platform confirmed.
unconfirmedHanded to the delivery ledger, no confirmation. The delivery obligation owns every retry from here; the outbox never resends.
failedNothing was sent and a human decides again. retry re-approves the same revision.
rejectedA human refused it. Terminal.
expiredOne of the two fixed windows lapsed. Terminal.

LEGAL_FROM in extensions/outbox/src/service.ts is the single declaration of which action may be taken from which state; the table test in extensions/outbox/src/__tests__/service.test.ts walks every (state, action) pair and asserts the service refuses the ones not listed. A refusal is illegal_transition when the action was never available, and conflict ("changed since you viewed it") when it was and something moved underneath the caller.

Revisions are immutable rows in outbox_revisions. Only the text is editable; destination and sender are fixed at propose. To publish somewhere else, reject the item and let the agent propose again.

A repeat proposal with the same (personalityId, contentHash) while an earlier item is still active returns the existing item instead of creating a second one (SQLiteOutboxStore.propose). That is idempotent proposal — a model retrying its tool call does not put two cards in front of the same person — not outbound dedup.

The content binding​

content_hash is the sha256 of a canonical JSON object with sorted keys:

// extensions/outbox/src/hash.ts — canonicalizeContent
{ botKey, chatId, personalityId, platform, text, threadId, v: 1 }

text is byte-exact: not trimmed, not normalized, anywhere on the path. Attachments are absent rather than optional — no gated path can carry one — and adding them bumps the version to v: 2 over each file's sha256 of bytes.

Three places enforce the binding:

  1. Approve is one conditional UPDATE … WHERE id = ? AND revision = ? AND content_hash = ? AND state = 'awaiting_approval' (SQLiteOutboxStore.approve). Zero rows changed means the caller gets conflict rather than publishing text nobody approved.
  2. An edit writes revision n+1 with a new hash and clears approved_by / approved_at / approved_revision in the same UPDATE (SQLiteOutboxStore.edit), so an approval cannot survive into the next revision.
  3. Immediately before delivery, OutboxService.verifyBinding recomputes the hash from the stored revision on the claimed row. A mismatch marks the item failed with binding mismatch and nothing is sent.

Sender resolution​

resolveSender (apps/ethos/src/lib/outbox-wiring.ts) decides once, at propose time, which bot will speak. The candidates are the bots on the target platform bound to the personality, directly or through a team manifest.

CandidatesOutcome
0Refused: CRON_TARGET_NOT_ALLOWED: no <platform> bot is bound to personality "<id>" — nothing was queued and nothing was sent.
1That bot.
More than 1, and the lane's botKey is among themThe lane's bot. The lane key is ${platform}:${botKey}:${chatId}, parsed by laneSenderBotKey / laneKeyBotKey.
More than 1, no usable laneRefused as an ambiguous sender. There is no fallback to "the first configured bot".

Reviewer​

approver_personality names a personality that runs one ordinary turn on the process's system loop before the human sees the item.

  • Advisory only. Every path out of createOutboxReviewer ends with a receipt attached and the item in awaiting_approval in front of a person. There is no transition from a verdict to approved: LEGAL_FROM.approve admits only awaiting_approval, and attachReview always sets awaiting_approval.
  • Read-only toolset. OUTBOX_REVIEW_TOOLS is read_file, memory_read, team_memory_read, team_memory_search, session_search, passed as toolsetNarrow (narrow can only subtract). send_message is absent.
  • Session key outbox-review:<id>:<rev>, excluded from the learning inbox via LEARNING_EXCLUDED_KEY_PREFIXES.
  • The draft is untrusted data. buildOutboxReviewPrompt wraps it with wrapUntrusted before it reaches the reviewer's context.
VerdictSet when
passPASS is the first word of the first line of the answer.
failFAIL is the first word of the first line. The item still reaches the human.
unclearAnything else, including an empty answer. Never coerced into pass or fail; the answer is shown verbatim.
unavailableNo reviewer named, the approver id is not a personality on this machine, no loop, a missing revision, a thrown review turn, or a review that has not come back within the stale threshold.

A human edit does not re-run the review. The receipt keeps the revision it read, and the card prefixes it with (reviewed revision n) when that is no longer the current one.

Delivery​

The dispatcher (createOutboxDispatcher) runs in the process that holds adapters, once after adapter.start() and then every OUTBOX_POLL_INTERVAL_MS (5 s) on an unref'd timer. Each tick expires, reconciles, claims and delivers — in that order.

Claiming is a conditional UPDATE … WHERE id = ? AND state = 'approved' over listClaimable(botKeys), which is itself filtered to this process's bots, so two gateways sharing one outbox.db each take a given row exactly once.

Gateway.deliverPublication then refuses or sends:

Refusal codeMeaningWhat the dispatcher does
bot_not_servedThis process does not hold that bot.Releases the claim; the item returns to approved.
no_adapterNo adapter on that platform for that bot. A sibling bot must not publish in its place.Releases the claim.
no_binding_checkNo publicationSpeaksFor is wired. A publication is not sent on the assumption that its bot is still bound.Releases the claim.
not_boundThe bot no longer speaks for the personality. The approval named that bot, so the approval itself is stale.Marks the item failed.
deduplicatedIdentical bytes already passed the outbound chokepoint under outbox:<id> inside the dedup TTL.Releases the claim; a later tick gets through once the TTL lapses.

On no refusal it calls sendTrackedDetailed with ledger session outbox:<id> and the byte-exact approved revision. confirmed: true marks the item sent. confirmed: false with an obligation id marks it unconfirmed, and the ledger's sweepPendingDeliveries owns the retry from then on. confirmed: false with no obligation id (no ledger wired) marks it failed, because saying unconfirmed would promise a sweep that cannot happen.

A stale sending row — claimed more than 10 minutes ago by a process that is gone — is reconciled against the ledger, which is the only thing that knows what happened: DeliveryLedger.findBySession('outbox:<id>') returning a row means unconfirmed; no row proves the platform call was never reached (sendTracked writes the obligation first), so the item becomes failed and waits for a human's Retry.

Fixed windows​

Module constants in extensions/outbox/src/store.ts. Not configuration, and there is no config key that changes them.

ConstantValueMeasured from
PENDING_EXPIRY_MS7 dayscreated_at — a reviewer receipt or an edit does not restart the clock.
APPROVAL_VALIDITY_MS24 hoursapproved_at — an approval nobody delivered inside the window expires.
STALE_THRESHOLD_MS10 minutesupdated_at for a review, claimed_at for a sending row.

Audit trail​

OutboxService writes one recordSafetyApproval row per human decision, with codes outbox.approve, outbox.reject, outbox.edit, outbox.revoke and outbox.retry (OUTBOX_AUDIT_CODES). approve and retry record as approved; reject, edit and revoke record as denied, separated by their code. The details carry the content hash, never the text.

Every surface that decides passes the sink: web-api's OutboxService (apps/web-api/src/index.ts), the runtimes in gateway.ts and boot.ts (a Telegram tap), ethos serve, and ethos outbox (runOutbox). The gateway, boot and serve sinks are pinned by apps/ethos/src/__tests__/outbox-gate-live.test.ts. The sink is optional in OutboxService itself and fail-open, so a broken sink costs an audit row, never a decision.

Limitations​

Each of these is a fact about the shipped code, not a caveat about intent.

  • Only a gateway process delivers. ethos serve queues but holds no adapters. An item it queues sits in approved until an ethos gateway start or ethos boot that holds the sending bot is running, and the approval expires after 24 hours. The commands with no outbox cannot publish at all — see Where the gate is wired.
  • MCP tools and a2a_send are not covered. Covering them would mean classifying arbitrary third-party tools. The character sheet prints the exclusion rather than letting the field read as blanket coverage.
  • The reviewer cannot approve or block. A human approves every publication.
  • Delivery is at-least-once. There is no exactly-once promise anywhere on this path: the ledger redelivers, and per-adapter honesty varies — Telegram's chunked send reports ok: true on a partially delivered multi-chunk message.
  • A sent publication cannot be unsent. Ethos offers no recall; the web pane says so and points at the platform.
  • A watcher with no recorded owner is never re-checked. WatcherManager.dispatchChange re-asks the gate on every change, so a watcher stored before the policy was switched on stops delivering to a foreign chat. It can ask only about a record that names the personality that created it (WatcherRecord.owner). Watchers created before owners were recorded carry none and still deliver. The watcher tools show and change only the calling personality's own watchers (loadOwnedWatcher in extensions/tools-watchers/src/index.ts), so no agent can delete an ownerless one: remove its entry from ~/.ethos/watchers/watchers.json and recreate it from the personality that should own it.
  • Approval cards exist on Telegram only, and only from the process holding the sending bot. postOutboxCard / updateOutboxCard / onOutboxDecision are implemented by TelegramAdapter alone, and createOutboxApprovalSurface posts a card only through the item's own bot's adapter. A publication on Slack or WhatsApp, or any item queued under ethos serve, gets no card: approve it in the web pane or with ethos outbox approve <id> --revision <n>.
  • On the web, any authenticated /rpc session counts as the operator (the rule deliveries already rides on). clientId on an approve is a label for the audit trail, not a gate.
  • Publications land at the chat root. send_message has no thread parameter, so threadId is always absent on a proposal.
  • channels is validated only when approve_before_send is present. A config.yaml with outbound_policy.channels and no outbound_policy.approve_before_send parses to nothing, gates nothing, and reports no unknown name.
  • An approved-but-unsent item counts as busy for the idle watcher (pendingPublications counts approved and sending). awaiting_approval deliberately does not — a machine that cannot suspend while a person thinks would never suspend.

See also​