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/khaki-eagles-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@getmunin/backend-core': minor
---

Inbound SMS threads into the existing conversation, and STOP unsubscribes

Webhook-mode channels used to open a brand-new conversation for every inbound message. Two texts from the same person were two unrelated conversations, and no reply could ever be attributed to what it was replying to. Inbound messages now land in that contact's most recent open conversation on the channel, reopening it if it was snoozed; a closed conversation still starts a fresh one. New conversations also inherit the channel's `defaultAgentMode`, which the generic ingest path was dropping while the email path honoured it.

An inbound SMS whose entire body is an opt-out keyword — `STOP`, `STOPP`, `SLUTT`, `AVMELD`, `UNSUBSCRIBE`, `END`, `QUIT`, `CANCEL`, `STOPALL`, case-insensitive, trailing punctuation ignored — now suppresses the CRM contact holding that number: `doNotContact` set, `unsubscribedAt` stamped, and a `crm_activities` note recording why. The message is still ingested so the conversation reads truthfully, and suppressed contacts drop out of `crm_list_contacts_in_segment`, so they leave every outreach audience. A message that merely contains the word ("can you cancel my order?") is an ordinary message.

Honouring STOP is a legal requirement for SMS in the US and expected practice in the EU, so this lands before SMS outreach rather than alongside it.
15 changes: 15 additions & 0 deletions .changeset/tall-pianos-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@getmunin/backend-core': minor
'@getmunin/dashboard-pages': minor
'@getmunin/types': minor
---

SMS channels can set how the agent handles inbound texts

`defaultAgentMode` has always been a `conv_channels` column, but only the email path could write it — the vendor-backed path that creates every SMS channel had no way to set it, and neither did the dashboard. Every SMS number was stuck on `auto`, replying to inbound texts automatically.

It is now settable on SMS channels through `conv_configure_channel`, the vendor tools, the `/v1` SMS endpoints, and a control in the Twilio and MessageBird dialogs alongside the one email already had. Set `draft_only` on a number you only run campaigns from and inbound replies are drafted for approval instead of auto-answered.

Voice channels reject it with an explanation rather than accepting a value that would do nothing: an inbound call is run by the vendor's assistant, not by the Munin agent, so there is no reply for the mode to govern.

Also corrects `conv_create_channel`'s description, which claimed "the `voice` and `sms` channel types are reserved and not yet wired to an adapter". Both SMS vendors and both voice vendors have shipped adapters; those channels are created with `conv_configure_channel`, which the description now says.
11 changes: 11 additions & 0 deletions .changeset/thin-plums-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@getmunin/backend-core': patch
'@getmunin/dashboard-pages': patch
'@getmunin/types': patch
---

SMS channel dialogs: agent-reply select on create, sender switching actually clears

The "Agent replies" select is now on the SMS create dialog, not just edit — a new channel no longer silently starts on `auto` until someone goes back and changes it. Renamed from "Default agent mode" / "Inbound replies" to a shared "Agent replies" across email and SMS dialogs, since "agent mode" is the database column name, not something an operator configuring a phone number has a mental model for.

Twilio's From-number and Messaging-Service-SID fields are now a single "Send from" choice instead of two always-visible inputs with an "either, both is also OK" caveat. Switching the choice on an existing channel now actually clears the field you switched away from — `updateChannel` previously merged with `?? prev`, so the old value survived a switch and both were sent on every message.
13 changes: 11 additions & 2 deletions packages/backend-core/docs-fixtures/mcp-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -2938,7 +2938,7 @@
{
"name": "conv_create_channel",
"title": "Conv: Create conversation channel",
"description": "Add a new conversation channel. Currently shipping adapters: `email` and `chat` (widget). Channel-specific configuration goes in `config`. The `voice` and `sms` channel types are reserved and not yet wired to an adapter.",
"description": "Add a new conversation channel. Channel-specific configuration goes in `config`. Use this for `email` and `chat` (widget) channels; `voice` and `sms` channels are vendor-backed and are created with conv_configure_channel instead, which handles their credential handoff.",
"audiences": [
"admin"
],
Expand Down Expand Up @@ -3905,7 +3905,7 @@
{
"name": "conv_configure_channel",
"title": "Conv: Configure a voice/SMS channel",
"description": "Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create.",
"description": "Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create. `defaultAgentMode` applies to SMS channels only.",
"audiences": [
"admin"
],
Expand Down Expand Up @@ -3934,6 +3934,15 @@
"minLength": 1,
"maxLength": 120
},
"defaultAgentMode": {
"description": "How the agent handles inbound messages on this channel: 'auto' replies directly, 'draft_only' files a draft for a human, 'off' does neither. SMS channels only — an inbound call is run by the vendor's assistant. Set 'draft_only' on an outreach-only number so replies are never auto-sent.",
"type": "string",
"enum": [
"auto",
"draft_only",
"off"
]
},
"config": {
"type": "object",
"propertyNames": {
Expand Down
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 @@ -186,7 +186,7 @@
"title": "Set up a voice or SMS channel",
"description": "Configure a Vapi/Threll voice channel or Twilio/MessageBird SMS channel with non-secret config, hand off the API keys through a credential link, and verify the result.",
"mimeType": "text/markdown",
"content": "# Set up a voice or SMS channel\n\nUse this when a customer wants Munin on a phone number — an AI voice line (Vapi, Threll) or two-way SMS (Twilio, MessageBird).\n\n## TL;DR\n\n1. `conv_list_channel_vendors` — see the supported vendors and each one's config fields; fields marked `secret: true` are never passed by you.\n2. `conv_configure_channel` with `vendor`, a `name`, and the **non-secret** config fields. The channel is created inactive and the response includes a one-time **credential link**.\n3. Share the credential link — the human enters the vendor API keys in the dashboard. Saving completes the vendor-side setup (webhook registration where applicable), verifies the credentials, and activates the channel. The link works once and expires after 24 hours; mint a fresh one with `conv_request_channel_credentials`.\n4. `conv_test_channel` re-verifies stored credentials any time; `conv_send_channel_test` (SMS) sends a real message.\n\n**Never ask for an API key, auth token, or signing key in the conversation** — the tool rejects secret fields.\n\n## Per-vendor non-secret config\n\n- **Vapi (voice)** — `assistantId` (required; the human finds it in the Vapi dashboard), `phoneNumberId` (only for PSTN calls), `publicKey` (only for in-browser voice via the widget), `replaceWebhook: true` if the assistant already has a non-Munin server URL. The link asks for the API key and a webhook secret of your choosing; on save Munin configures the assistant's server URL automatically.\n- **Threll (voice)** — `workerId` (required; from the Threll webapp), `accountId` (optional, resolved from the API key). The link asks for the API key; on save Munin creates the webhook subscription and stores the signing secret Threll returns.\n- **Twilio (SMS)** — `accountSid` (required) plus `fromNumber` or `messagingServiceSid`. The link asks for the auth token.\n- **MessageBird (SMS)** — `originator` (required). The link asks for the access key and signing key.\n\n## While the channel is pending\n\nA channel waiting on its credential link is `active: false` and every admin action on it (`conv_test_channel`, `conv_send_channel_test`, `conv_list_channel_options`, updates) answers `conv_invalid: channel is awaiting credentials`. If the link expired, mint a new one with `conv_request_channel_credentials { channelId }`.\n\n## Picking assistant/worker ids\n\n`conv_list_channel_options` lists a vendor's selectable options (Vapi assistants, Threll workers) **using a channel's stored credentials** — so it works only after the credential link is completed. For the initial create, the human reads the id from the vendor dashboard; to switch later, complete setup first, then call `conv_list_channel_options { channelId }` and update with `conv_configure_channel`.\n\n## Verify\n\n- `conv_test_channel { channelId }` — vendor-shaped credential check (Twilio account fetch, MessageBird balance, etc.), no message sent.\n- SMS: `conv_send_channel_test { channelId, to }` sends a real message end-to-end.\n- Voice: there is no tool that places a call. A human verifies the channel end-to-end from the dashboard — Channels → the channel's ⋯ menu → **Make a test call**.\n"
"content": "# Set up a voice or SMS channel\n\nUse this when a customer wants Munin on a phone number — an AI voice line (Vapi, Threll) or two-way SMS (Twilio, MessageBird).\n\n## TL;DR\n\n1. `conv_list_channel_vendors` — see the supported vendors and each one's config fields; fields marked `secret: true` are never passed by you.\n2. `conv_configure_channel` with `vendor`, a `name`, and the **non-secret** config fields. The channel is created inactive and the response includes a one-time **credential link**.\n3. Share the credential link — the human enters the vendor API keys in the dashboard. Saving completes the vendor-side setup (webhook registration where applicable), verifies the credentials, and activates the channel. The link works once and expires after 24 hours; mint a fresh one with `conv_request_channel_credentials`.\n4. `conv_test_channel` re-verifies stored credentials any time; `conv_send_channel_test` (SMS) sends a real message.\n\n**Never ask for an API key, auth token, or signing key in the conversation** — the tool rejects secret fields.\n\n## Per-vendor non-secret config\n\n- **Vapi (voice)** — `assistantId` (required; the human finds it in the Vapi dashboard), `phoneNumberId` (only for PSTN calls), `publicKey` (only for in-browser voice via the widget), `replaceWebhook: true` if the assistant already has a non-Munin server URL. The link asks for the API key and a webhook secret of your choosing; on save Munin configures the assistant's server URL automatically.\n- **Threll (voice)** — `workerId` (required; from the Threll webapp), `accountId` (optional, resolved from the API key). The link asks for the API key; on save Munin creates the webhook subscription and stores the signing secret Threll returns.\n- **Twilio (SMS)** — `accountSid` (required) plus `fromNumber` or `messagingServiceSid`. The link asks for the auth token.\n- **MessageBird (SMS)** — `originator` (required). The link asks for the access key and signing key.\n\n## While the channel is pending\n\nA channel waiting on its credential link is `active: false` and every admin action on it (`conv_test_channel`, `conv_send_channel_test`, `conv_list_channel_options`, updates) answers `conv_invalid: channel is awaiting credentials`. If the link expired, mint a new one with `conv_request_channel_credentials { channelId }`.\n\n## Picking assistant/worker ids\n\n`conv_list_channel_options` lists a vendor's selectable options (Vapi assistants, Threll workers) **using a channel's stored credentials** — so it works only after the credential link is completed. For the initial create, the human reads the id from the vendor dashboard; to switch later, complete setup first, then call `conv_list_channel_options { channelId }` and update with `conv_configure_channel`.\n\n## Inbound behaviour\n\nReplies thread. A second message from the same number on the same channel lands in that number's most recent open conversation rather than opening a new one; a snoozed conversation is reopened. A new conversation is started only when the previous one was closed.\n\n**Set `defaultAgentMode` per SMS channel.** `conv_configure_channel { channelId, vendor, defaultAgentMode }` decides what the agent does with texts arriving on that number: `auto` replies directly, `draft_only` files a draft for a human to approve, `off` does neither. Use `draft_only` on a number you only send campaigns from, so a reply is never auto-answered. It applies to SMS only — an inbound call is run by the vendor's assistant, not the Munin agent, so passing it on a voice channel is rejected.\n\n**SMS opt-out is automatic and irreversible from your side.** An inbound SMS whose entire body is an opt-out keyword — `STOP`, `STOPP`, `SLUTT`, `AVMELD`, `UNSUBSCRIBE`, `END`, `QUIT`, `CANCEL`, `STOPALL`, case-insensitive, trailing punctuation ignored — suppresses the CRM contact holding that phone number: `doNotContact` is set, `unsubscribedAt` is stamped, and a `crm_activities` note records it. The message is still ingested so the conversation reads truthfully. A sentence that merely contains one of those words (\"can you cancel my order?\") is an ordinary message and suppresses nothing.\n\nSuppressed contacts drop out of `crm_list_contacts_in_segment`, so they stop appearing in outreach audiences. Do not clear the flag to re-add someone — if they ask to opt back in, that is a new consent decision a human records.\n\n## Verify\n\n- `conv_test_channel { channelId }` — vendor-shaped credential check (Twilio account fetch, MessageBird balance, etc.), no message sent.\n- SMS: `conv_send_channel_test { channelId, to }` sends a real message end-to-end.\n- Voice: there is no tool that places a call. A human verifies the channel end-to-end from the dashboard — Channels → the channel's ⋯ menu → **Make a test call**.\n"
},
{
"uri": "skill://conv/strip-email-signature",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { and, eq } from 'drizzle-orm';
import { getCurrentContext } from '@getmunin/core';
import { schema } from '@getmunin/db';
import type { AgentMode } from '@getmunin/types';
import {
CHANNEL_ADMIN_PROVIDERS,
PENDING_SETUP_KEY,
Expand Down Expand Up @@ -47,24 +48,48 @@ export class ChannelAdminService {
channelId?: string;
name?: string;
config: Record<string, unknown>;
defaultAgentMode?: AgentMode;
},
opts?: { rejectSecrets?: boolean },
): Promise<ChannelAdminDto> {
const provider = this.requireVendor(input.vendor);
if (opts?.rejectSecrets) this.assertNoSecrets(provider, input.config);
if (input.defaultAgentMode && provider.kind !== 'sms') {
throw new BadRequestException(
`defaultAgentMode does not apply to ${provider.kind} channels — an inbound call is run by the vendor's assistant, not by the Munin agent`,
);
}
if (input.channelId) await this.assertNotPending(input.channelId);
if (opts?.rejectSecrets && !input.channelId) {
return this.createPending(provider, { name: input.name, config: input.config });
const pending = await this.createPending(provider, {
name: input.name,
config: input.config,
});
return this.applyDefaultAgentMode(pending, input.defaultAgentMode);
}
const parsed = provider.configInput.safeParse(input.config);
if (!parsed.success) {
throw new BadRequestException(`invalid config for ${input.vendor}: ${parsed.error.message}`);
}
return provider.configure({
const configured = await provider.configure({
channelId: input.channelId,
name: input.name,
config: parsed.data,
});
return this.applyDefaultAgentMode(configured, input.defaultAgentMode);
}

private async applyDefaultAgentMode(
channel: ChannelAdminDto,
defaultAgentMode: AgentMode | undefined,
): Promise<ChannelAdminDto> {
if (!defaultAgentMode) return channel;
const ctx = getCurrentContext();
await ctx.db
.update(schema.convChannels)
.set({ defaultAgentMode, updatedAt: new Date() })
.where(eq(schema.convChannels.id, channel.id));
return { ...channel, defaultAgentMode };
}

async completeSetup(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { z } from 'zod';
import { McpTool } from '@getmunin/mcp-toolkit';
import { sensitive } from '@getmunin/types';
import { AgentModeSchema, sensitive } from '@getmunin/types';
import { ChannelAdminService } from './channel-admin.service.ts';
import { ChannelCredentialService } from './channel-credential.service.ts';
import type { ChannelAdminDto } from './channel-admin.ts';
Expand All @@ -18,6 +18,9 @@ const ConfigureInput = z.object({
.optional()
.describe('Pass an existing channel id to update; omit to create a new channel.'),
name: z.string().min(1).max(120).optional().describe('Channel display name. Required on create.'),
defaultAgentMode: AgentModeSchema.optional().describe(
"How the agent handles inbound messages on this channel: 'auto' replies directly, 'draft_only' files a draft for a human, 'off' does neither. SMS channels only — an inbound call is run by the vendor's assistant. Set 'draft_only' on an outreach-only number so replies are never auto-sent.",
),
config: sensitive(
z
.record(z.string(), z.unknown())
Expand Down Expand Up @@ -84,7 +87,7 @@ export class ChannelAdminTools {
name: 'conv_configure_channel',
title: 'Conv: Configure a voice/SMS channel',
description:
'Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create.',
'Create or update a voice or SMS channel for any supported vendor. Pass `vendor` + the vendor’s non-secret `config` fields (see conv_list_channel_vendors). Secret fields are rejected here: creating returns a pending channel plus a one-time link for a human to enter the secrets in the dashboard — the channel activates once they are saved and verified. Pass `channelId` to update; omit to create. `defaultAgentMode` applies to SMS channels only.',
audiences: ['admin'],
scopes: ['conv:write'],
input: ConfigureInput,
Expand All @@ -100,6 +103,7 @@ export class ChannelAdminTools {
channelId: args.channelId,
name: args.name,
config: args.config,
defaultAgentMode: args.defaultAgentMode,
},
{ rejectSecrets: true },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ChannelAdminDto {
vendor: string;
active: boolean;
config: unknown;
defaultAgentMode?: string;
}

export interface ConfigureChannelInput {
Expand Down
Loading
Loading