Skip to content

feat(outreach): SMS outreach campaigns - #701

Merged
kmonsoe merged 2 commits into
mainfrom
feat/outreach-sms-campaigns
Jul 30, 2026
Merged

feat(outreach): SMS outreach campaigns#701
kmonsoe merged 2 commits into
mainfrom
feat/outreach-sms-campaigns

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

PR 4b of the outbound-calling series. Stacked on #700.

An outreach campaign can now run on an SMS channel: outreach_propose_initial drafts a text against the contact's phone, and a person approving it in the dashboard creates the conversation and sends. The gate from #699 already covers sms, so this lands protected — an agent can draft a text but never send one.

Outbound SMS was never delivered

Worth calling out separately because it isn't specific to outreach. ConvService enqueued a conv_message_deliveries row only when channelType === 'email', in both createConversation and sendMessage. Every other path was fine — the OutboundDeliveryWorker is generic and both SMS adapters implement send() correctly — but nothing ever handed them a row.

So today, an agent or operator replying on an SMS conversation stores a message that silently never sends. No error, no failed delivery, just nothing. The only way an SMS ever left Munin was conv_send_channel_test.

The gate now covers email and SMS. Voice and chat stay out: voice has no text delivery, and the widget is pull-based.

SMS-shaped composition

composeOutreachBody emits markdown — \n\n---\n[Unsubscribe](url) — which arrives in a text as literal brackets. Texts get their own composition: the campaign CTA appended as a bare URL, and Reply STOP to opt out. when the campaign requires it. That pairs with the STOP handling from #700, so the instruction we print is one the system actually honours.

Drafts are capped at 480 characters, about three billable segments, rejected above it with the limit stated in the tool schema so a curator writes to it rather than discovering it.

Scope

Follow-up sequences stay email-only — an SMS campaign rejects sequenceSteps and outreach_propose_followup still refuses a text conversation. One touch, then the reply flow, which works now that inbound texts thread into the outreach conversation (#700) and carry the campaign id.

findOrCreateContactByPhone was duplicated in the Vapi and Threll adapters and inlined a third time in the outreach voice path. It's one shared helper now, taking the source tag as a parameter so each caller keeps its own provenance.

Tests

Seven new cases: the channel is accepted and reports the phone as delivery.destination; a draft over the cap is rejected; a contact with no phone is rejected; an agent cannot approve; a human approval sends the composed body, queues exactly one delivery row, and opens the conversation in draft_only linked to the campaign; the opt-out line is omitted when the campaign doesn't require unsubscribe; sequenceSteps are rejected.

The send test asserts the literal body — 'Hei! Vi har lansert noe du ba om. Reply STOP to opt out.' — and that it contains no ](, so a markdown regression fails loudly.

Full suite green at 1319 tests; typecheck clean across 31 tasks.

Skill

skill://outreach/draft-initial-sms — the writing rules an agent needs and can't infer: the 480 cap and what a segment costs, why an emoji silently switches the message to UCS-2 and triples the price, that markdown arrives raw, that the opt-out line is appended so don't write one, and that approving is a dashboard-only action so filing the proposal is where the agent stops.

🤖 Generated with Claude Code

https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm

@kmonsoe
kmonsoe force-pushed the feat/outreach-sms-campaigns branch from be47d3a to 6dda797 Compare July 30, 2026 09:33
@kmonsoe
kmonsoe force-pushed the feat/outreach-sms-channel branch from 94dfff1 to 81c6a87 Compare July 30, 2026 10:38
kmonsoe and others added 2 commits July 30, 2026 12:46
A campaign can run on an SMS channel. proposeInitial drafts against the
contact's phone, capped at 480 characters — about three billable segments —
and a person approving in the dashboard creates the conversation and sends.
Composition is SMS-shaped: bare CTA URL and "Reply STOP to opt out." rather
than the markdown footer email gets.

Outbound SMS was never delivered. ConvService enqueued a delivery row only
for channelType === 'email', so a reply on an SMS conversation was stored
and silently never sent; the worker and both adapters were fine, nothing was
ever handed to them. The gate now covers email and sms.

Follow-up sequences stay email-only. One touch, then the reply flow, which
works because inbound texts now thread into the outreach conversation.

findOrCreateContactByPhone was duplicated across the Vapi and Threll
adapters and inlined a third time in the outreach voice path; it is one
shared helper now, taking the source as a parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
…ampaign

Said "channelId must reference an email channel" — untrue since this PR
added SMS as a valid outreach channel type, and voice campaigns predate
this series entirely. Now states all three channel types and the
dashboard-only approval restriction for SMS and voice, which a reviewer
or drafting agent needs to know before creating a campaign on one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
@kmonsoe
kmonsoe force-pushed the feat/outreach-sms-campaigns branch from 6dda797 to 4661d14 Compare July 30, 2026 10:47
@kmonsoe
kmonsoe changed the base branch from feat/outreach-sms-channel to main July 30, 2026 10:47
@kmonsoe
kmonsoe merged commit eedcba5 into main Jul 30, 2026
3 checks passed
@kmonsoe
kmonsoe deleted the feat/outreach-sms-campaigns branch July 31, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant