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.
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.
How one batch works
- Record explicit consent evidence for the recipients you may contact.
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.
- Read the returned recipients, exclusions, final message, provider blockers,
preview_revision, eligible_count, and message_hash.
- Arm that exact preview with a replay-safe action reference.
- 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.
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:
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:
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:
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:
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
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.
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.
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.