> ## Documentation Index
> Fetch the complete documentation index at: https://docs.erdo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Bounded outreach

> Preview, approve, and audit consent-gated SMS to an explicit operator-selected audience

# Bounded outreach

Bounded outreach turns an operator's explicit selection from a reviewed lead
snapshot into an auditable SMS batch. Erdo checks every selected person, shows
every exclusion, fixes the message and complete selection into a preview, and
sends only after a separate arm request confirms the exact preview a person
reviewed. Erdo never chooses the first N people or splits a selection into
automatic batches.

This is a deliberately narrow surface for controlled outreach. It is not a
campaign builder: there are no editable cadences, branches, recurring schedules,
personalization rules, or automatic follow-ups.

<Warning>
  Every REST request and MCP connection for outreach requires a validated
  `X-Project-ID`. Omitting it is an error; Erdo never falls back to all projects.
  The authenticated user must also be a persisted owner or admin of the active
  organization; ordinary members and project-scoped external tokens cannot read
  consent evidence or control provider actions.
  The `bounded_outreach` rollout must also be enabled for the caller. Outside the
  rollout, reads and writes return a clear setup error rather than an empty list.
</Warning>

## How one batch works

1. Record explicit consent evidence for the recipients you may contact.
2. `PUT` the exact 1 to 700 recipients selected by the operator into a named
   draft. The 700-person limit is a pilot safety ceiling, not a default or
   automatic target.
3. Read the returned recipients, exclusions, final message, provider blockers,
   `preview_revision`, `eligible_count`, and `message_hash`.
4. Arm that exact preview with a replay-safe action reference.
5. Read recipient receipts as Erdo records provider acceptance, delivery,
   failure, replies, and opt-outs.

Creating or reading a draft never sends a message. Erdo appends `Reply STOP to
opt out.` to the reviewed text and returns the final body and hash. Eligible
recipients are assigned deterministically: one-fifth, rounded up, is held for
seven days and the rest is due immediately. For any authorised eligible set,
the delayed count is `ceil(eligible_count / 5)` and every other eligible
recipient is immediate.

## Consent evidence

Consent is a separate resource because a boolean in a batch would only repeat
the caller's claim. A grant records where the evidence came from, which version
of that evidence was reviewed, the stable lead identity and phone it covers,
and when consent was granted.

```bash theme={null}
curl -X PUT https://api.erdo.ai/v1/outreach-consents \
  -H "Authorization: Bearer $ERDO_API_KEY" \
  -H "X-Organization-ID: acme" \
  -H "X-Project-ID: 01234567-89ab-cdef-0123-456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_ref": "crm-consent-lead-104-v1",
    "recipient_ref": "lead-104",
    "phone_raw": "(305) 555-0123",
    "default_country": "US",
    "basis": "explicit_opt_in",
    "source_ref": "signed-form-8842",
    "evidence_version": "sms-consent-2026-08",
    "evidence_text": "I agree to receive text messages about Northline Residences.",
    "granted_at": "2026-08-20T14:25:00Z"
  }'
```

The pilot accepts `explicit_opt_in` only. `evidence_text` is required and stores
the exact consent wording or source excerpt that was reviewed, up to 4,000
characters. Replaying the same `grant_ref` and evidence returns the stored
grant. Reusing it for different evidence returns a conflict.

Revoke without deleting the audit record:

```bash theme={null}
curl -X PUT https://api.erdo.ai/v1/outreach-consents \
  -H "Authorization: Bearer $ERDO_API_KEY" \
  -H "X-Organization-ID: acme" \
  -H "X-Project-ID: 01234567-89ab-cdef-0123-456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"grant_ref":"crm-consent-lead-104-v1","revoked":true}'
```

List grants with `GET /v1/outreach-consents?limit=25`; pass the returned
`next_cursor` as `cursor` for the next page. Read one with
`?grant_ref=crm-consent-lead-104-v1`, or with `recipient_ref`, `phone_raw`, and
`default_country` when the source does not already know the grant reference.

## Create the immutable preview

The batch slug is the resource address inside the active project. The source
snapshot, initiative and sequence step are part of the immutable content:

```bash theme={null}
curl -X PUT https://api.erdo.ai/v1/outreach-batches/selected-leads-aug-25 \
  -H "Authorization: Bearer $ERDO_API_KEY" \
  -H "X-Organization-ID: acme" \
  -H "X-Project-ID: 01234567-89ab-cdef-0123-456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "source_snapshot_ref": "historical-leads-2026-08-24-sha256-abcd",
    "initiative_ref": "historical-lead-follow-up",
    "sequence_step": "initial-sms",
    "default_country": "US",
    "message_body": "Hi — this is the Northline Residences team. Are you still considering a home in Miami?",
    "message_version": "initial-sms-v1",
    "recipients": [
      {"recipient_ref":"lead-104","phone_raw":"(305) 555-0123"},
      {"recipient_ref":"lead-105","phone_raw":"+13055550124","identity_status":"ambiguous"}
    ]
  }'
```

Erdo, not the caller, normalizes `phone_raw`, matches the consent ledger, checks
generic SMS and outreach-wide suppression, checks prior contact for the same
initiative step, and assigns cohorts. Missing evidence does not reject the
preview. The recipient remains visible with `eligibility_state: "excluded"`
and an `exclusion_reason` such as:

* `ambiguous_identity`
* `invalid_phone`
* `duplicate_recipient_ref`
* `duplicate_canonical_phone`
* `cross_batch_duplicate_recipient_ref`
* `cross_batch_duplicate_canonical_phone`
* `channel_suppressed` or `outreach_suppressed`
* `prior_sequence_step_contact`
* `prior_sequence_step_selection`
* `no_matching_explicit_consent`

Provider and contact-hours setup are also preview facts.
`provider_readiness` checks the dedicated outreach Twilio account and number,
its pin to the active project, registration approval, the HTTPS status callback,
and the independent dispatch switch. `policy_readiness` checks that the
organization has enabled a valid contact-hours window and timezone. A failed
check remains visible in its `blockers`; `armable` stays false and
`arm_blockers` contains the combined reasons.

The provider blocker codes are
`twilio_sms_outreach_account_sid_missing`,
`twilio_sms_outreach_account_not_dedicated`,
`twilio_root_credentials_missing`,
`twilio_sms_outreach_phone_number_missing`,
`twilio_sms_outreach_project_mismatch`,
`twilio_status_callback_not_configured`,
`twilio_sms_outreach_registration_not_approved`, and
`twilio_sms_outreach_dispatch_not_enabled`. Contact-hours policy reports
`outreach_contact_hours_policy_missing`,
`outreach_contact_hours_policy_unavailable`, or
`outreach_contact_hours_policy_disabled`, or the more specific
`outreach_contact_hours_window_invalid` and
`outreach_contact_hours_timezone_invalid`.

`provider_cost_estimate` shows the configured USD price per segment, segments
per recipient, estimated segment count, and estimated cost for the eligible
audience. When pricing is not configured, `available` is false and
`unavailable_reason` explains the missing fact; Erdo does not invent a cost.

An operator may create another explicit batch for the same `initiative_ref` and
`sequence_step` after an earlier batch is armed. Erdo never broadens that later
selection: anyone already contacted is excluded as
`prior_sequence_step_contact`, while anyone still reserved by an active earlier
batch is excluded as `prior_sequence_step_selection`. Duplicate draft
selections remain visibly excluded rather than silently choosing one copy. The
lifetime initiative-step pool cannot grow beyond 700 distinct recipient
references, so multiple batches cannot bypass the pilot safety ceiling.

Repeating the PUT with the same immutable input re-evaluates consent,
suppression, prior contact, cross-batch duplicates, cohorts, counts, and the
preview revision across the still-draft initiative step. It never sends.
Changing the source, recipients, message, version, initiative, or step under
the same slug returns a conflict; create a new batch slug for a new review.

## Arm, pause, resume, or cancel

Actions use one endpoint and always require a stable `action_ref`. Repeating an
action reference returns its stored receipt; it never performs the transition
again.

Arming also compares the facts shown during review:

```bash theme={null}
curl -X POST https://api.erdo.ai/v1/outreach-batches/selected-leads-aug-25/actions \
  -H "Authorization: Bearer $ERDO_API_KEY" \
  -H "X-Organization-ID: acme" \
  -H "X-Project-ID: 01234567-89ab-cdef-0123-456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "arm",
    "action_ref": "operator-approval-2026-08-24T1800Z",
    "expected_preview_revision": "PREVIEW_REVISION_FROM_GET",
    "expected_eligible_count": 37,
    "expected_message_hash": "MESSAGE_HASH_FROM_GET"
  }'
```

If any expected value differs, nothing starts. `pause`, `resume`, and `cancel`
bind the click to the exact immutable source, state, and batch version shown to
the operator:

```json theme={null}
{
  "action": "pause",
  "action_ref": "pause-selected-leads-aug-25-running-v1",
  "expected_source_snapshot_ref": "historical-leads@01234567-89ab-4def-8123-456789abcdef",
  "expected_batch_state": "running",
  "expected_batch_updated_at": "2026-08-25T10:15:30.123456Z"
}
```

Use all three expected values from the same batch read. If the request outcome
is unclear, retry the identical body. Erdo returns the original receipt with a
stable `acted_at`; changing the body under the same `action_ref` is rejected.
A new action reference with stale facts is also rejected, even if a later
pause/resume cycle returns the batch to the same named state. These actions
affect contact that has not reached the provider and cannot recall a message
already accepted by the provider.

## Read batches and recipient receipts

```text theme={null}
GET /v1/outreach-batches?initiative_ref=historical-lead-follow-up&limit=25&cursor=...
GET /v1/outreach-batches/selected-leads-aug-25
GET /v1/outreach-batches/selected-leads-aug-25/recipients?limit=100&cursor=...
```

Each list item includes `batch`, `counts`, `armable`, `arm_blockers`,
`provider_readiness`, `policy_readiness`, and `provider_cost_estimate`. A
workspace can therefore render factual totals and blockers without fetching
every batch detail first.

Batch state is `draft`, `running`, `holding`, `paused`, `cancelled`, or
`completed`. Recipient delivery state distinguishes draft, excluded, queued,
`sending`, `provider_accepted`, `delivered`, `failed`, `held`,
`outcome_unknown`, and `cancelled`. Response state is separate: `none`,
`replied`, or `opted_out`. Do not treat delivery as a reply or add overlapping
counts together. The batch summary's `contacted` value is the unique number of
recipients accepted by the provider or beyond.

`outcome_unknown` is a separate delivery state for the dangerous case where a
provider request may have succeeded but Erdo did not receive a conclusive
response. Erdo pauses the whole batch and will not send or resume it while that
receipt is unresolved. Every send includes signed per-recipient scope in its
status callback URL, so a later provider callback can attach the missing message
ID and repair the message ledger without sending again. `resume` remains blocked
until that callback resolves the unknown state; a retry never guesses that the
original send failed.

Use `initiative_ref` when a workspace belongs to one outreach initiative. Erdo
applies the filter before pagination, so an app never fetches the newest page
across unrelated initiatives and then drops the batch it needed.

Recipient states are deliberately not one ambiguous server filter: eligibility,
delivery, and response are separate axes. Page through the complete immutable
selection with `limit=100` and each returned `next_cursor`, then filter the exact
axis in the client (`eligibility_state`, `delivery_state`, or `response_state`).
Summary counts and arm validation always cover the complete selection, never
only the current page.

A STOP reply marks the receipt opted out, suppresses the stable lead across
later batches in the project, and cancels any queued or held contact for that
lead. Replies that cannot be tied to a stored outbound message are not attached
to a guessed batch.

## MCP tools

The MCP surface uses the same authenticated project and the same service logic:

* `erdo_put_outreach_batch`
* `erdo_list_outreach_batches`
* `erdo_get_outreach_batch`
* `erdo_list_outreach_batch_recipients`
* `erdo_act_on_outreach_batch`
* `erdo_put_outreach_consent`
* `erdo_list_outreach_consents`
* `erdo_get_outreach_consent`

Add `X-Project-ID` to the MCP connection headers. A raw MCP client cannot use a
scoped API key; that existing fail-closed rule remains unchanged.

## CLI

Every outreach command requires the global `--project` context. Use `--org` as
well when the key can operate more than one organization.

```bash theme={null}
erdo --org acme --project "$PROJECT_ID" outreach consents put --file consent.json
erdo --org acme --project "$PROJECT_ID" outreach consents list
erdo --org acme --project "$PROJECT_ID" outreach consents get crm-consent-lead-104-v1
erdo --org acme --project "$PROJECT_ID" outreach consents get \
  --recipient-ref lead-104 --phone "+13055550123" --country US

erdo --org acme --project "$PROJECT_ID" outreach batches put selected-leads-aug-25 --file batch.json
erdo --org acme --project "$PROJECT_ID" outreach batches list --initiative historical-lead-follow-up
erdo --org acme --project "$PROJECT_ID" outreach batches get selected-leads-aug-25
erdo --org acme --project "$PROJECT_ID" outreach batches get selected-leads-aug-25 --recipients
erdo --org acme --project "$PROJECT_ID" outreach batches act selected-leads-aug-25 arm \
  --action-ref operator-approval-01 \
  --preview-revision "$PREVIEW_REVISION" \
  --eligible-count "$ELIGIBLE_COUNT" \
  --message-hash "$MESSAGE_HASH"
```

For revocation, the consent file contains only `grant_ref` and
`"revoked": true`. List commands accept `--limit` and `--cursor`.

## Scoped API keys

A server can mint a key with `outreach:read`, `outreach:send`, or both.
`outreach:send` covers consent writes, draft creation, and lifecycle actions.
It is deliberately not implied by `resources:write`: arming outreach can contact
people and spend money. Both capabilities remain confined to the authenticated
project header and still require the key's user to be a persisted organization
owner or admin.
