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/quiet-lions-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@getmunin/backend-core': patch
---

Add a skill for drafting outbound calls

`skill://outreach/draft-initial-call` covers the voice-campaign pass, which had no skill of its own — an agent had to infer from the email skill that `draftBody` on a voice campaign is what a text-to-speech agent says out loud, not a message that gets delivered.

It says what only a spoken channel needs said: write speech rather than prose, no markdown or emoji (read aloud or mangled), no URLs or reference codes (nobody can click on a phone call), identify the caller in the first sentence, give the voice agent a goal and boundaries rather than a script, and tell it what to do when the person is busy or asks whether it is a human. It also sets a higher bar for who is worth calling at all — matching a segment filter is a reason to email, not to phone — and states that approval is dashboard-only, so filing the proposal is where the agent stops.

Cross-links the three first-touch skills to each other and lists all of them in `review-proposals`, which named only the email ones.
15 changes: 12 additions & 3 deletions packages/backend-core/docs-fixtures/skills.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
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.
audiences: [admin]
---

# Draft an initial call

Same 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.

**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.

Take 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.

## The pass

1. **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`.
2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis.
3. **Skip contacts with no `phone`** — `outreach_propose_initial` rejects them.
4. **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`.
5. **Dedupe** via `outreach_list_proposals({ kind: "initial", campaignId, contactId })` as the email pass does.
6. **File** with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a call has no subject, and passing one is rejected.
7. **Stop.**

## Writing for a voice agent

- **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.
- **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.
- **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.
- **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.
- **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.
- **Use the recipient's language.** A Norwegian number gets Norwegian.

Good:

`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.`

Bad:

`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.

## What happens on approval

A 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.

Follow-up sequences are not available on voice campaigns. One call, then whatever the conversation becomes.

## Related

- `skill://outreach/draft-initial-email` — the same pass where you may write at length.
- `skill://outreach/draft-initial-sms` — the short-form written equivalent.
- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.
- `skill://conv/setup-voice-sms-channel` — configuring the voice channel the campaign runs on.
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ Out of scope for this skill — see `skill://outreach/review-proposals`. The ope

## Related

- `skill://outreach/draft-initial-sms` — the same pass on an SMS campaign, capped at 480 characters of plain text.
- `skill://outreach/draft-initial-call` — the same pass on a voice campaign, where you draft what an AI agent says when the call connects.

- `skill://outreach/draft-followup-email` — drafts the next sequence step when an initial filed by this pass sits unanswered past a campaign's `sequenceSteps` wait period.
- `skill://kb/review-content` — symmetric pattern (per-conversation curator that proposes, human approves) for KB instead of outreach.
- `skill://crm/clean-contact-data` — population-level dedup that catches duplicates this and other curators create.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@ Follow-up sequences are email-only. An SMS campaign cannot carry `sequenceSteps`
## Related

- `skill://outreach/draft-initial-email` — the same pass for email, where you may draft at length.
- `skill://outreach/draft-initial-call` — the spoken equivalent, with a higher bar for who is worth contacting.
- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.
- `skill://conv/setup-voice-sms-channel` — configuring the number, including `defaultAgentMode` and how STOP suppression works.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ audiences: [admin]

# Review pending outreach proposals

Every outbound email in Munin ships through a human-approved gate: curators file drafts as **pending proposals** (`skill://outreach/draft-initial-email`, `skill://outreach/draft-reply-email`, `skill://outreach/draft-followup-email`), and nothing leaves the org until an operator — or an admin agent acting on their explicit instruction — decides each one. This skill is that decision pass.
Every outbound message in Munin ships through a human-approved gate: curators file drafts as **pending proposals** (`skill://outreach/draft-initial-email`, `skill://outreach/draft-initial-sms`, `skill://outreach/draft-initial-call`, `skill://outreach/draft-reply-email`, `skill://outreach/draft-followup-email`), and nothing leaves the org until an operator — or an admin agent acting on their explicit instruction — decides each one. This skill is that decision pass.

**Calls and text messages are approved in the dashboard, never here.** A proposal whose campaign runs on a voice or SMS channel can only be approved by a signed-in person in the Munin dashboard. `outreach_approve_proposal` refuses every other caller — an agent, an admin API key, the Slack button — with `outreach_invalid: … approved by a signed-in person in the Munin dashboard`. That is the safety floor for outbound calling, not a configuration you can route around: don't retry, don't look for another tool, and don't ask for a credential that would work. Present the draft, say it is waiting for someone to place the call from the dashboard inbox, and stop. You can still `outreach_revise_proposal`, `outreach_withdraw_proposal`, and `outreach_dismiss_proposal` on these — none of them send anything.

Expand Down
Loading