feat(outreach): place outreach calls through any registered voice vendor - #703
Merged
Conversation
kmonsoe
force-pushed
the
docs/outreach-voice-skill
branch
from
July 30, 2026 09:33
72e19fe to
a5ecf1e
Compare
kmonsoe
force-pushed
the
feat/outreach-voice-vendors
branch
from
July 30, 2026 09:36
63fbf3d to
3a12819
Compare
kmonsoe
force-pushed
the
docs/outreach-voice-skill
branch
from
July 30, 2026 10:47
a5ecf1e to
50a60f3
Compare
loadOutreachChannel accepted voice:vapi and nothing else, so a campaign could not run on Threll despite Threll having shipped an adapter, admin service and dashboard support alongside Vapi. The voice path was written against one vendor: it called VapiClientService directly, parsed config with Vapi's schema, and hard-coded vapiCallId as the key linking a call to its conversation. Voice outreach now resolves an OutreachVoiceCaller by channel.vendor — vendor name, the metadata key its conversations are keyed by, one placeOutreachCall method. A third vendor is a class and a line in the module. A voice channel whose vendor has no caller is refused at campaign-create time, naming the ones that work, rather than at approval. Each vendor keeps the linkage its own webhook expects: Vapi carries the draft in assistantOverrides.metadata keyed on vapiCallId, Threll passes it as call context keyed on threllCallId. Both partial unique indexes already existed, so no migration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
The previous commit on this branch edited draft-initial-call.md (added the "every supported voice vendor works the same way here" line) but didn't run docs:generate afterward, so the fixture shipped out of sync with the markdown it's generated from. 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-voice-vendors
branch
from
July 30, 2026 10:48
3a12819 to
c876f0d
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.
Final PR of the outbound-calling series. Stacked on #702.
The gap
loadOutreachChannelacceptedvoice:vapiand nothing else, so a campaign could not run on a Threll channel — even though Threll has shipped an adapter, an admin service, credential handling and dashboard support for as long as Vapi has. You spotted this in the error message.It was an accident of the voice path being written against one vendor rather than a deliberate limit.
approveInitialVoicecalledVapiClientServicedirectly, parsed config with Vapi's schema, and hard-codedvapiCallIdas the key linking a placed call back to its conversation — including inside theON CONFLICTclause that makes the stub insert race-safe against the vendor webhook.The seam
Voice outreach now resolves an
OutreachVoiceCallerbychannel.vendor:Registered per vendor via
OUTREACH_VOICE_CALLERSinConvModule, the same multi-injection patternCHANNEL_ADAPTERSuses. Adding a third vendor is a class implementing that interface plus a line in the module — nothing inOutreachServiceneeds to know it exists.A voice channel whose vendor has no registered caller is refused at campaign-create time, naming the vendors that do work, rather than succeeding and then failing at approval when someone is waiting to place a call.
Vendor differences the seam preserves
The two vendors link calls to conversations differently, and each webhook expects its own shape, so the caller owns both halves:
assistantOverrides.metadata, and keys conversations onvapiCallId.assistantOverrides; itsplaceCalltakes acontextstring, which is what the worker reads. It keys onthrellCallId.The stub-conversation insert now builds its conflict target from the caller's
callIdMetadataKey. Both partial unique indexes already exist —0026for Vapi and0040for Threll — so no migration. I wrote one, smoke-tested it against a scratch DB at 0058 per the repo's rule, found the index already present from 0040, and deleted it. That check is the reason this PR touches no migrations.Client dependencies are typed as narrow
VapiOutreachClient/ThrellOutreachClientinterfaces rather than the concrete services, so the tests stub them without a cast.Tests
Six unit tests on the callers: each reports the right metadata key; Vapi sends the draft and outreach ids as assistant metadata; Threll sends the draft as call
contextand maps the name tocustomer.firstName; a Vapi channel with nophoneNumberIdis refused before the vendor is called; an unnamed contact sends nocustomer.Two service tests: a Threll voice campaign is created and its proposal reports
vendor: 'threll'with the phone as destination; a voice channel on an unregistered vendor is refused with the supported list.Full suite green at 1327 tests;
pnpm typecheckclean across 31 tasks.skill://outreach/draft-initial-callpicks up a line saying every supported vendor behaves the same here, so a curator writes for the channel rather than a provider.🤖 Generated with Claude Code
https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm