Skip to content

feat(outreach): place outreach calls through any registered voice vendor - #703

Merged
kmonsoe merged 2 commits into
mainfrom
feat/outreach-voice-vendors
Jul 30, 2026
Merged

feat(outreach): place outreach calls through any registered voice vendor#703
kmonsoe merged 2 commits into
mainfrom
feat/outreach-voice-vendors

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Final PR of the outbound-calling series. Stacked on #702.

The gap

loadOutreachChannel accepted voice:vapi and 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. approveInitialVoice called VapiClientService directly, parsed config with Vapi's schema, and hard-coded vapiCallId as the key linking a placed call back to its conversation — including inside the ON CONFLICT clause that makes the stub insert race-safe against the vendor webhook.

The seam

Voice outreach now resolves an OutreachVoiceCaller by channel.vendor:

interface OutreachVoiceCaller {
  readonly vendor: string;
  readonly callIdMetadataKey: string;
  placeOutreachCall(input: PlaceOutreachCallInput): Promise<{ callId: string; status: string }>;
}

Registered per vendor via OUTREACH_VOICE_CALLERS in ConvModule, the same multi-injection pattern CHANNEL_ADAPTERS uses. Adding a third vendor is a class implementing that interface plus a line in the module — nothing in OutreachService needs 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:

  • Vapi carries the draft and the outreach ids in assistantOverrides.metadata, and keys conversations on vapiCallId.
  • Threll has no assistantOverrides; its placeCall takes a context string, which is what the worker reads. It keys on threllCallId.

The stub-conversation insert now builds its conflict target from the caller's callIdMetadataKey. Both partial unique indexes already exist — 0026 for Vapi and 0040 for 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 / ThrellOutreachClient interfaces 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 context and maps the name to customer.firstName; a Vapi channel with no phoneNumberId is refused before the vendor is called; an unnamed contact sends no customer.

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 typecheck clean across 31 tasks. skill://outreach/draft-initial-call picks 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

@kmonsoe
kmonsoe force-pushed the docs/outreach-voice-skill branch from 72e19fe to a5ecf1e Compare July 30, 2026 09:33
@kmonsoe
kmonsoe force-pushed the feat/outreach-voice-vendors branch from 63fbf3d to 3a12819 Compare July 30, 2026 09:36
@kmonsoe
kmonsoe force-pushed the docs/outreach-voice-skill branch from a5ecf1e to 50a60f3 Compare July 30, 2026 10:47
kmonsoe and others added 2 commits July 30, 2026 12:48
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
kmonsoe force-pushed the feat/outreach-voice-vendors branch from 3a12819 to c876f0d Compare July 30, 2026 10:48
@kmonsoe
kmonsoe changed the base branch from docs/outreach-voice-skill to main July 30, 2026 10:48
@kmonsoe
kmonsoe merged commit 3269f5c into main Jul 30, 2026
3 checks passed
@kmonsoe
kmonsoe deleted the feat/outreach-voice-vendors 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