# AGENTS.md — Outbound Platform Usage Contract

This document is the contract for any agent (Claude, future agents) operating against the outbound email platform. Per the Agent-First framework Principle 2 (Contract First), it lives in the repo and is read on every prep run. Agents that violate this contract refuse the tool call.

The contract is **belt**; the Worker is **suspenders**. Server-side enforcement is the source of truth — these rules exist so a well-behaved agent fails fast and surfaces the right context to the operator before the Worker rejects the call.

## 1. Identity rules

- Operators are Workspace users. The agent cannot send as anyone outside the operators table.
- Agents are not operators. The agent does not create or send a sequence under its own identity. Every sequence has a human owner whose email is the From: address.
- Agents act on behalf of an operator. Tool calls specify owner_email. The agent acts as that operator — with their permissions, suppression list, daily cap, and audit trail.
- Auth boundary. Every Worker API call requires a per-operator JWT obtained from Supabase Auth. JWTs expire in 15 min; the agent requests refresh on 401. Tokens are never persisted to disk.
- No cross-operator action. Even with a valid JWT, a token issued to operator A cannot mutate operator B's resources.

## 2. Sequencing rules

- Suppression is enforced at add time — personalize_add_recipients returns rejected[].reason='suppressed:...' (and 'suppressed_cc:...' for a blocked CC). No separate pre-check tool.
- One active sequence per (operator, recipient) pair.
- Stop on reply. Replied recipients stop receiving stages.
- No bypassing the send window (8am–8pm in the sequence's IANA timezone, no holidays).
- State transitions are versioned (optimistic lock on state_version).
- No 'opened' trigger. Use 'always', 'no_reply', or 'no_engagement'. The 'no_engagement' trigger fires only for recipients with no reply, no click, and no human-shaped open (filters APMP/Image-Proxy pixel pre-fetches).

## 3. Approval gates (destructive actions)

| Recipient count | Required header |
|---|---|
| 1–50 | (none) |
| 51–500 | X-Approval: confirm:<approver_email> |
| 501–1,000 | X-Approval: multipov:<review_id> |
| 1,001+ | X-Approval: token:<approval_token> |

The agent does not skip any gate. Tokens expire 24h after issue.

## 4. Snippet rules

- Snippet edits create a new version (set parent_id; old row stays as reference).
- Shared snippets require co-sign from a second operator.
- Variables must be declared in the snippet's variables column.
- Variable validation at add time, not send time.

## 5. Forbidden patterns

- Recipient with double-@ or missing TLD.
- Body with literal {{ or }} after substitution.
- Sequence name containing (Copy).
- More than 5 stages in one sequence.
- Bulk add of >2,000 recipients in one tool call.
- Recipient with same domain as a 'do_not_contact' suppression.
- Multi-stage sequence whose owner has no active Gmail watch.

## 6. Rate limits

- Per-operator daily cap (default 1000) and per-minute cap (default 30).
- Per-Worker per-second send rate gated by Cloudflare Workers Paid limits.
- 429 from Gmail API → exponential backoff, 5 attempts max → dead-letter.

## 7. Audit trail

Every agent action writes to audit_log:

```
actor:           email or 'agent:claude' / 'agent:&lt;name&gt;'
action:          verb (create_sequence, add_recipients, launch, operator.update, etc.)
resource_type:   sequence | recipient | snippet | suppression | operator | approval
resource_id:     uuid (null when the resource is keyed by email like operators)
payload:         non-PII metadata only (counts, IDs, error codes, timestamps)
occurred_at:     timestamptz
```

PII prohibition. payload stores structured non-PII metadata only. Recipient email addresses, names, or rendered template variables do not belong in payload.

The audit log is queryable via personalize_audit (operators auto-scoped to own rows; admin sees all). It is not purged.

## 8. Read-only operations (no contract beyond auth)

- personalize_get_status
- personalize_search_snippets
- personalize_audit
- personalize_list_sequences
- GET /api/dashboard — server-side HTML R/O view (admin sees all operators)

## 9. Failure handling (Autonomous Recovery)

- Gmail API 429 → exponential backoff up to 5 attempts; then dead-letter.
- Gmail send failure (4xx, not rate limit) → mark send 'failed', write error_message.
- Reply detection failure (push + poll both miss for >10 min) → Slack alert; operator must intervene.
- Approval token expiry (24h) → sequence stays in draft; operator can re-request.
- Click-allowlist miss → Worker returns 400 to the click endpoint; agent surfaces affected attempts to the operator.

## 10. Surface

This document is the deployed copy — fetched from the host you're calling, so it matches the contract that is running.

## 11. iMessage outbound (Josh-only v1)

iMessage send is available only on sequences owned by `joshuabaer@capitalfactory.com` with at least one stage where `stage.medium='imessage'`. Per-recipient prerequisites: `phone_e164` set, `phone_consent_for_imessage=true`, `phone_consent_at` non-null, `phone_consent_disclosure_text` recording the verbatim consent text. The Worker enforces all four server-side — a non-compliant row never reaches the daemon.

- **Consent capture** — `personalize_register_recipient_phone` (Josh-only). Captures phone + consent verbiage + consent timestamp; audit-logs SHA-256 of the phone.
- **STOP / revocation** — `personalize_revoke_recipient_phone` flips the flag + inserts an iMessage suppression row. Inbound "STOP" on the daemon's chat.db tail triggers the same path automatically.
- **Transport** — mikeybot Mac mini runs the `personalize-imessaged` daemon (LaunchAgent `com.capitalthought.personalize-imessaged`). Daemon polls `/api/imessage/outbound/pending` every ~10s, dispatches via AppleScript to Messages.app, posts back `/sent` or `/failed`. Daemon health visible at `imessage_daemon_health.last_seen_at` (≤ 5 min = healthy).
- **Rate limits** — per-operator daily cap `operators.imessage_daily_send_cap` (default 50, per IM4-A); the Worker counts sends per UTC day and skips beyond the cap with `error_message='daily_imessage_cap_exhausted:<count>'`.
- **Pacing** — design doc `docs/plans/2026-05-09-imessage-texting-design.md` §3 mandates ≥30s between sends and quiet hours of 9am-8pm operator-local. The daemon enforces both; agents should NOT batch large cohorts as a single launch.
- **Runbook** — operational install / rotate / debug procedures live in `docs/runbooks/imessage-daemon.md`. If the daemon is unhealthy or you see `daemon_disabled` 503 from `/api/imessage/outbound/pending`, that runbook is the canonical recovery path.
- **Decommission trigger** — if Apple cease-and-desist OR AppleScript automation gets removed in a future macOS, migrate to the v2 Twilio path per design doc §7. The recipient consent + audit history persists; only transport is severed.

🔧
