Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/olive-buttons-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@getmunin/backend-core': minor
---

Outreach calls work on any registered voice vendor, not only Vapi

`loadOutreachChannel` accepted `voice:vapi` and nothing else, so a campaign could not run on a Threll channel even though Threll has shipped an adapter, admin service and dashboard support for as long as Vapi has. The restriction was an accident of the voice path being written against one vendor: `approveInitialVoice` called `VapiClientService` directly, read config with Vapi's parser, and hard-coded `vapiCallId` as the key linking a call back to its conversation.

Voice outreach now goes through a small `OutreachVoiceCaller` seam — vendor name, the metadata key its conversations are keyed by, and one `placeOutreachCall` method — registered per vendor and resolved by `channel.vendor`. Adding a third vendor is a class implementing that interface and 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 failing at approval.

Each vendor keeps the linkage its own webhook expects: Vapi carries the draft and the outreach ids in `assistantOverrides.metadata` and keys conversations on `vapiCallId`, Threll passes the draft as call `context` and keys on `threllCallId`. Both partial unique indexes already existed (0026 and 0040), so no migration was needed.
2 changes: 1 addition & 1 deletion packages/backend-core/docs-fixtures/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@
"title": "Outreach: Draft an initial call",
"description": "Draft the opening and talking points for a first-touch outbound call on a voice campaign. One pending proposal per (campaign, contact). Only a signed-in person in the Munin dashboard can place the call — an agent never can, on any host.",
"mimeType": "text/markdown",
"content": "# Draft an initial call\n\nSame pass as `skill://outreach/draft-initial-email`, for a campaign running on a voice channel. What you write is not a message that gets delivered — it is the **opening and talking points an AI voice agent speaks** when the call connects. Everything after the first few seconds is a live conversation you don't control.\n\n**You cannot place the call.** `outreach_approve_proposal` refuses every caller that is not a signed-in dashboard user: agents, admin API keys, the Slack button. That is the safety floor for outbound calling and there is no argument, tool or credential that gets around it. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all work — none of them dial.\n\nTake the asymmetry seriously. A cold email is ignorable and a text is cheap; an unsolicited AI phone call interrupts someone, cannot be un-rung, and in most of Europe is the most heavily regulated thing Munin can do. Draft fewer, better calls than you would emails.\n\n## The pass\n\n1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Confirm the campaign is on a voice channel before writing spoken copy — an existing proposal shows `delivery.channelType`.\n2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis.\n3. **Skip contacts with no `phone`** — `outreach_propose_initial` rejects them.\n4. **Be stricter than the segment.** The segment says you *may* contact them. Calling asks whether you *should*: a call needs a reason this specific person would welcome one — they asked to be called, they started something and stopped, they are mid-deal. \"They match the filter\" is a reason to email, not to phone. Skip the rest and say why in `evidence`.\n5. **Dedupe** via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })` as the email pass does.\n6. **File** with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a call has no subject, and passing one is rejected.\n7. **Stop.**\n\n## Writing for a voice agent\n\n- **Write speech, not prose.** It will be read aloud by a text-to-speech voice. Short sentences. No markdown, no bullet characters, no headings, no emoji — they are either spoken literally or mangled.\n- **No URLs, no email addresses, no reference codes.** Nobody can click a link on a phone call, and a spelled-out address is painful. If there is something to send, the point of the call is to earn permission to send it.\n- **Say who is calling, from where, and why, in the first sentence.** A silent or meandering opening is how an AI call gets hung up on, and it is what a recipient's complaint will quote.\n- **Give the agent a goal and boundaries, not a script to recite.** It handles the conversation; you set the intent. Say what to do if the person is busy — offer to call back, don't push.\n- **Say what the agent must not do**: don't claim to be human if asked, don't quote prices or commitments you haven't given it, don't keep someone who says no.\n- **Use the recipient's language.** A Norwegian number gets Norwegian.\n\nGood:\n\n`Open: \"Hei, dette er Munin-assistenten som ringer på vegne av Kjell hos Munin. Du ba om en oppringing da du testet onboarding-flyten — passer det å snakke i to minutter?\" If busy: offer to call back and end the call. Goal: book a 20-minute demo, propose Tuesday or Thursday morning. If asked whether this is a real person: say plainly it is an AI assistant. Don't discuss pricing — say Kjell will follow up by email.`\n\nBad:\n\n`Hi! 👋 We wanted to reach out about **our new onboarding flow** — check it out at https://getmunin.com/onboarding and book a slot!` — emoji and markdown read aloud, a URL nobody can use, no identification, no goal, no boundaries.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin hands the campaign's voice channel the destination number and your draft as the assistant's opening context, then creates a stub conversation linked to the campaign so the transcript lands somewhere. Approval is refused — before dialling — if the campaign is inside its `cadenceRules` quiet hours or on a blackout date. Quiet hours are read in the campaign's `quietHoursTimezone`, so set one; without it they are read in UTC, which is not what \"no calls before 08:00\" means anywhere in Europe.\n\nFollow-up sequences are not available on voice campaigns. One call, then whatever the conversation becomes.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass where you may write at length.\n- `skill://outreach/draft-initial-sms` — the short-form written equivalent.\n- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.\n- `skill://conv/setup-voice-sms-channel` — configuring the voice channel the campaign runs on.\n"
"content": "# Draft an initial call\n\nSame pass as `skill://outreach/draft-initial-email`, for a campaign running on a voice channel. What you write is not a message that gets delivered — it is the **opening and talking points an AI voice agent speaks** when the call connects. Everything after the first few seconds is a live conversation you don't control.\n\n**You cannot place the call.** `outreach_approve_proposal` refuses every caller that is not a signed-in dashboard user: agents, admin API keys, the Slack button. That is the safety floor for outbound calling and there is no argument, tool or credential that gets around it. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all work — none of them dial.\n\nTake the asymmetry seriously. A cold email is ignorable and a text is cheap; an unsolicited AI phone call interrupts someone, cannot be un-rung, and in most of Europe is the most heavily regulated thing Munin can do. Draft fewer, better calls than you would emails.\n\n## The pass\n\n1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Confirm the campaign is on a voice channel before writing spoken copy — an existing proposal shows `delivery.channelType`.\n2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis.\n3. **Skip contacts with no `phone`** — `outreach_propose_initial` rejects them.\n4. **Be stricter than the segment.** The segment says you *may* contact them. Calling asks whether you *should*: a call needs a reason this specific person would welcome one — they asked to be called, they started something and stopped, they are mid-deal. \"They match the filter\" is a reason to email, not to phone. Skip the rest and say why in `evidence`.\n5. **Dedupe** via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })` as the email pass does.\n6. **File** with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a call has no subject, and passing one is rejected.\n7. **Stop.**\n\n## Writing for a voice agent\n\n- **Write speech, not prose.** It will be read aloud by a text-to-speech voice. Short sentences. No markdown, no bullet characters, no headings, no emoji — they are either spoken literally or mangled.\n- **No URLs, no email addresses, no reference codes.** Nobody can click a link on a phone call, and a spelled-out address is painful. If there is something to send, the point of the call is to earn permission to send it.\n- **Say who is calling, from where, and why, in the first sentence.** A silent or meandering opening is how an AI call gets hung up on, and it is what a recipient's complaint will quote.\n- **Give the agent a goal and boundaries, not a script to recite.** It handles the conversation; you set the intent. Say what to do if the person is busy — offer to call back, don't push.\n- **Say what the agent must not do**: don't claim to be human if asked, don't quote prices or commitments you haven't given it, don't keep someone who says no.\n- **Use the recipient's language.** A Norwegian number gets Norwegian.\n\nGood:\n\n`Open: \"Hei, dette er Munin-assistenten som ringer på vegne av Kjell hos Munin. Du ba om en oppringing da du testet onboarding-flyten — passer det å snakke i to minutter?\" If busy: offer to call back and end the call. Goal: book a 20-minute demo, propose Tuesday or Thursday morning. If asked whether this is a real person: say plainly it is an AI assistant. Don't discuss pricing — say Kjell will follow up by email.`\n\nBad:\n\n`Hi! 👋 We wanted to reach out about **our new onboarding flow** — check it out at https://getmunin.com/onboarding and book a slot!` — emoji and markdown read aloud, a URL nobody can use, no identification, no goal, no boundaries.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin hands the campaign's voice channel the destination number and your draft as the assistant's opening context — every supported voice vendor works the same way here, so write for the channel, not for a particular provider — then creates a stub conversation linked to the campaign so the transcript lands somewhere. Approval is refused — before dialling — if the campaign is inside its `cadenceRules` quiet hours or on a blackout date. Quiet hours are read in the campaign's `quietHoursTimezone`, so set one; without it they are read in UTC, which is not what \"no calls before 08:00\" means anywhere in Europe.\n\nFollow-up sequences are not available on voice campaigns. One call, then whatever the conversation becomes.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass where you may write at length.\n- `skill://outreach/draft-initial-sms` — the short-form written equivalent.\n- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.\n- `skill://conv/setup-voice-sms-channel` — configuring the voice channel the campaign runs on.\n"
},
{
"uri": "skill://outreach/draft-initial-email",
Expand Down
144 changes: 144 additions & 0 deletions packages/backend-core/src/modules/conv/channels/outreach-voice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import type { schema } from '@getmunin/db';
import { VapiOutreachCaller, type VapiOutreachClient } from '../vapi/vapi-outreach-caller.ts';
import {
ThrellOutreachCaller,
type ThrellOutreachClient,
} from '../threll/threll-outreach-caller.ts';
import type { PlaceOutreachCallInput } from './outreach-voice.ts';

const CONTEXT = {
outreachCampaignId: 'ocmp_1',
outreachProposalId: 'oprp_1',
contactId: 'cct_1',
};

function channel(
vendor: string,
config: Record<string, unknown>,
): typeof schema.convChannels.$inferSelect {
return { id: 'cch_1', orgId: 'org_1', type: 'voice', vendor, config } as never;
}

function input(
ch: typeof schema.convChannels.$inferSelect,
overrides: Partial<PlaceOutreachCallInput> = {},
): PlaceOutreachCallInput {
return {
channel: ch,
toNumber: '+4712345678',
customerName: 'Jane Doe',
opening: 'Say hei, then offer Tuesday or Thursday.',
context: CONTEXT,
...overrides,
};
}

describe('VapiOutreachCaller', () => {
const vapiChannel = channel('vapi', {
encryptedApiKey: 'enc-key',
encryptedWebhookSecret: 'enc-hook',
assistantId: 'asst_1',
phoneNumberId: 'pn_1',
});

function stub(): { client: VapiOutreachClient; calls: Record<string, unknown>[] } {
const calls: Record<string, unknown>[] = [];
return {
calls,
client: {
loadSecret: (ciphertext) => Promise.resolve(`plain:${ciphertext}`),
placeCall: (req) => {
calls.push(req);
return Promise.resolve({ id: 'call_vapi_1', status: 'queued' });
},
},
};
}

it('keys its conversations on vapiCallId', () => {
expect(new VapiOutreachCaller(stub().client).callIdMetadataKey).toBe('vapiCallId');
});

it('passes the draft as assistant metadata alongside the outreach ids', async () => {
const { client, calls } = stub();
const res = await new VapiOutreachCaller(client).placeOutreachCall(input(vapiChannel));

expect(res).toEqual({ callId: 'call_vapi_1', status: 'queued' });
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
apiKey: 'plain:enc-key',
assistantId: 'asst_1',
phoneNumberId: 'pn_1',
toNumber: '+4712345678',
customer: { name: 'Jane Doe' },
assistantOverrides: {
metadata: { ...CONTEXT, draftOpening: 'Say hei, then offer Tuesday or Thursday.' },
},
});
});

it('refuses a channel with no phone number id rather than calling the vendor', async () => {
const { client, calls } = stub();
const noPhone = channel('vapi', {
encryptedApiKey: 'enc-key',
encryptedWebhookSecret: 'enc-hook',
assistantId: 'asst_1',
});
await expect(
new VapiOutreachCaller(client).placeOutreachCall(input(noPhone)),
).rejects.toThrow(/phoneNumberId/);
expect(calls).toHaveLength(0);
});
});

describe('ThrellOutreachCaller', () => {
const threllChannel = channel('threll', {
encryptedApiKey: 'enc-key',
encryptedWebhookSecret: 'enc-hook',
accountId: 'acct_1',
workerId: 'wrk_1',
});

function stub(): { client: ThrellOutreachClient; calls: Record<string, unknown>[] } {
const calls: Record<string, unknown>[] = [];
return {
calls,
client: {
loadSecret: (ciphertext) => Promise.resolve(`plain:${ciphertext}`),
placeCall: (req) => {
calls.push(req);
return Promise.resolve({ id: 'call_threll_1', status: 'ringing' });
},
},
};
}

it('keys its conversations on threllCallId, not vapiCallId', () => {
expect(new ThrellOutreachCaller(stub().client).callIdMetadataKey).toBe('threllCallId');
});

it('passes the draft as call context, which is what Threll reads', async () => {
const { client, calls } = stub();
const res = await new ThrellOutreachCaller(client).placeOutreachCall(input(threllChannel));

expect(res).toEqual({ callId: 'call_threll_1', status: 'ringing' });
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
apiKey: 'plain:enc-key',
accountId: 'acct_1',
workerId: 'wrk_1',
toNumber: '+4712345678',
context: 'Say hei, then offer Tuesday or Thursday.',
customer: { firstName: 'Jane Doe' },
});
});

it('omits the customer when the contact has no name', async () => {
const { client, calls } = stub();
await new ThrellOutreachCaller(client).placeOutreachCall(
input(threllChannel, { customerName: undefined }),
);
expect(calls[0]!.customer).toBeUndefined();
});
});
23 changes: 23 additions & 0 deletions packages/backend-core/src/modules/conv/channels/outreach-voice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { schema } from '@getmunin/db';

export const OUTREACH_VOICE_CALLERS = Symbol('OUTREACH_VOICE_CALLERS');

export interface OutreachCallContext {
outreachCampaignId: string;
outreachProposalId: string;
contactId: string;
}

export interface PlaceOutreachCallInput {
channel: typeof schema.convChannels.$inferSelect;
toNumber: string;
customerName?: string;
opening: string;
context: OutreachCallContext;
}

export interface OutreachVoiceCaller {
readonly vendor: string;
readonly callIdMetadataKey: string;
placeOutreachCall(input: PlaceOutreachCallInput): Promise<{ callId: string; status: string }>;
}
11 changes: 11 additions & 0 deletions packages/backend-core/src/modules/conv/conv.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { ChannelCredentialTools } from './channels/channel-credential.tools.ts';
import { CredentialHandoffModule } from '../credential-handoff/credential-handoff.module.ts';
import { CredentialTargetRegistry } from '../credential-handoff/credential-target.ts';
import { VapiAdminProvider } from './vapi/vapi-admin.provider.ts';
import { VapiOutreachCaller } from './vapi/vapi-outreach-caller.ts';
import { ThrellOutreachCaller } from './threll/threll-outreach-caller.ts';
import { OUTREACH_VOICE_CALLERS } from './channels/outreach-voice.ts';
import { ThrellAdminProvider } from './threll/threll-admin.provider.ts';
import { TwilioSmsAdminProvider } from './twilio/twilio-sms-admin.provider.ts';
import { MessageBirdSmsAdminProvider } from './messagebird/messagebird-sms-admin.provider.ts';
Expand Down Expand Up @@ -107,6 +110,13 @@ import { WidgetThrottlerGuard } from './widget/widget-throttler.guard.ts';
WidgetChannelAdminService,
WidgetAdminTools,
WidgetThrottlerGuard,
VapiOutreachCaller,
ThrellOutreachCaller,
{
provide: OUTREACH_VOICE_CALLERS,
useFactory: (vapi: VapiOutreachCaller, threll: ThrellOutreachCaller) => [vapi, threll],
inject: [VapiOutreachCaller, ThrellOutreachCaller],
},
{
provide: CHANNEL_ADAPTERS,
useFactory: (
Expand Down Expand Up @@ -149,6 +159,7 @@ import { WidgetThrottlerGuard } from './widget/widget-throttler.guard.ts';
},
],
exports: [
OUTREACH_VOICE_CALLERS,
ConvService,
ConversationClaimsService,
EmailService,
Expand Down
Loading
Loading