feat(outreach): SMS outreach campaigns - #701
Merged
Merged
Conversation
kmonsoe
force-pushed
the
feat/outreach-sms-campaigns
branch
from
July 30, 2026 09:33
be47d3a to
6dda797
Compare
kmonsoe
force-pushed
the
feat/outreach-sms-channel
branch
from
July 30, 2026 10:38
94dfff1 to
81c6a87
Compare
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
force-pushed
the
feat/outreach-sms-campaigns
branch
from
July 30, 2026 10:47
6dda797 to
4661d14
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 4b of the outbound-calling series. Stacked on #700.
An outreach campaign can now run on an SMS channel:
outreach_propose_initialdrafts 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 coverssms, 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.
ConvServiceenqueued aconv_message_deliveriesrow only whenchannelType === 'email', in bothcreateConversationandsendMessage. Every other path was fine — theOutboundDeliveryWorkeris generic and both SMS adapters implementsend()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
composeOutreachBodyemits 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, andReply 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
sequenceStepsandoutreach_propose_followupstill 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.findOrCreateContactByPhonewas duplicated in the Vapi and Threll adapters and inlined a third time in the outreach voice path. It's one shared helper now, taking thesourcetag 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 indraft_onlylinked to the campaign; the opt-out line is omitted when the campaign doesn't require unsubscribe;sequenceStepsare 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