Chapter 39 — The Outreach Agent
Policy returned PASS on O-1001. Maya approved the draft. Customer contact is the irreversible step — only Outreach may deliver.
Outreach receives an already-reviewed draft and runs it through preference, opt-out, authorization (outreach:send), Policy re-check at send time (send window for SMS/push, required fields), approval binding, idempotency, and audit before any channel adapter acts.
approved draft → prefs/opt-out → authz → Policy re-check (window + fields) → approval_id → idempotent send → audit
Drafting without this gate stack is how duplicate sends and after-hours SMS happen. A draft written at 20:00 can still fail at 21:30 when the send window closes. Opt-out can flip between draft and delivery. Retries without idempotency spam Jordan twice.
On O-1001, Outreach checks channel preference, confirms not opted out, verifies approval, sends with idempotency key O-1001:wa:1, and records a consequential audit event. If any gate fails, no transport call.
This chapter adds Outreach: the only component with customer-delivery capability — and every gate in front of it.
First principles
- Use the customer’s selected channel. Multi-channel support means preference plus Policy, not “blast all.”
- Separate drafting from delivery. Only delivery needs the
outreach:sendcapability (Ch 27–28). - Re-check Policy at send time. Drafts can age; send window and opt-out can change between draft and delivery.
- Treat opt-out as a hard stop. This design requires a separate, out-of-band consent process before any later contact.
- Make delivery idempotent. Every send carries a key so retries cannot duplicate an order update (Ch 6, 32).
- Start with recording adapters. A mock makes every attempted side effect visible before a real provider is connected.
- Leave evidence behind. Audit every consequential delivery (Ch 30).
Concrete example
O-1001 has a stalled shipment. The customer selected WhatsApp, Policy returned PASS, a reviewer approved the send, and the idempotency key is O-1001:wa:1.
DraftMessage(channel=whatsapp, body=shipment update for O-1001)
→ OutreachAgent.send(principal with outreach:send, approval_id=…)
→ MockTransport records one send
→ AuditLog consequential_action
If opted_out flips to true before send, Outreach raises PermissionError and makes no transport call. If the approval is missing, it also denies delivery.
Diagram
sequenceDiagram
participant P as Policy
participant O as Outreach
participant A as Authz
participant T as Channel transport
participant U as Audit
P->>O: approved draft
O->>A: require outreach:send
A-->>O: allow / deny
O->>T: send(idem_key)
O->>U: record_consequential_action
prefs / opt-out ──► draft
policy PASS ──► gate
authz send scope ──► gate
approval (voice/threshold) ──► gate
idempotent transport ──► side effect
audit append ──► evidence
Caption: Notice how many gates sit in front of the carrier — the model is not among the gatekeepers.
Implementation
Module: shopops/agents/outreach.py.
from shopops.agents.outreach import (
Channel,
OutreachAgent,
MockTransport,
PreferenceStore,
)
from shopops.agents.policy_agent import PolicyReview, PolicyReviewStatus
from shopops.authz import Authz
from shopops.audit import AuditLog
from shopops.policy.packs.shop_v1 import POLICY_VERSION
prefs = PreferenceStore()
prefs.set("C-7781", preferred_channel="whatsapp", opted_out=False)
transport = MockTransport()
audit = AuditLog()
authz = Authz()
outreach = OutreachAgent(prefs=prefs, transport=transport, authz=authz, audit=audit)
draft = outreach.draft(
case_id="O-1001",
customer_id="C-7781",
body="Your shipment for order O-1001 has stalled. We are reviewing a reship or refund.",
to="+15550001111", # fictional
order_evidence_complete=True,
)
assert draft.channel == Channel.WHATSAPP
principal = authz.with_send_capability(
authz.principal_for_agent("contact", "store_northline")
)
policy_review = PolicyReview(
case_id="O-1001",
status=PolicyReviewStatus.PASS,
policy_version=POLICY_VERSION,
rule_ids=("contact_ok",),
reasons=("ok",),
required_fields_ok=True,
)
result = outreach.send(
principal=principal,
draft=draft,
policy_review=policy_review,
approval_id="appr-77",
idem_key="O-1001:wa:1",
policy_version=POLICY_VERSION,
tenant_id="store_northline",
state_snapshot={"fsm": "executing"},
)
# double send same key → same message_id
result2 = outreach.send(
principal=principal,
draft=draft,
policy_review=policy_review,
approval_id="appr-77",
idem_key="O-1001:wa:1",
policy_version=POLICY_VERSION,
tenant_id="store_northline",
state_snapshot={"fsm": "executing"},
)
assert result["message_id"] == result2["message_id"]
assert len(transport.sends) == 1
prefs.opt_out("C-7781")
try:
outreach.send(
principal=principal,
draft=draft,
policy_review=policy_review,
approval_id="appr-77",
idem_key="O-1001:wa:2",
policy_version=POLICY_VERSION,
tenant_id="store_northline",
state_snapshot={},
)
raise AssertionError("opt-out must block")
except PermissionError:
pass
Failure modes
| Failure | Incident | Fix |
|---|---|---|
| Send without policy PASS | Illegal contact | Require PASS |
| Ignoring opt-out | Regulatory event | Hard check forever |
| No idempotency | Double WhatsApp | Keys at adapter |
| Model picks channel | Preference bypass | Pref store owns channel |
| Audit after crash | Lost evidence | Append-before-ack patterns |
Production considerations
- Real adapters: timeouts, provider webhooks, delivery receipts → ledger.
- Recheck the policy verdict immediately before delivery; order state may have changed since the draft.
- Frequency counters from ledger, not from model memory.
- Redact phone/email in traces; full values only in sealed audit store.
Chapter summary
- Outreach owns channels under prefs and gates.
- Draft ≠ send; delivery needs approval.
- Opt-out honored forever in this design.
- Idempotent transports prevent double sends.
- Policy PASS and authz scopes are mandatory.
- Audited consequential actions only.
- Mocks first; real carriers later.
- Never unrestricted autonomous customer outreach.
Exercises
- Mechanical. Opt out; prove draft-for-send path raises; escalate path remains available at orchestrator level.
- Idempotency. Concurrent double
sendwith same key; one message_id. - Design. Specify webhook handling when WhatsApp delivery fails after ack — compensation without second marketing ping.
References
- Chapters 6, 27–30, 32, 38
- Channel provider idempotency docs. [VERIFY]