diff --git a/.changeset/quiet-lions-speak.md b/.changeset/quiet-lions-speak.md new file mode 100644 index 00000000..e201baee --- /dev/null +++ b/.changeset/quiet-lions-speak.md @@ -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. diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index 8eca1651..6f9ec49e 100644 --- a/packages/backend-core/docs-fixtures/skills.json +++ b/packages/backend-core/docs-fixtures/skills.json @@ -296,6 +296,15 @@ "mimeType": "text/markdown", "content": "# Draft outreach follow-ups\nCampaigns may define an ordered follow-up sequence (`sequenceSteps` on the campaign: each step is `waitDays` + a short drafting `brief` like \"gentle bump\" or \"share a relevant case study\"). When an initial (or previous follow-up) has sat unanswered past the step's wait period, this pass drafts the next step and files it as a `kind: 'followup'` proposal for human approval — same Review tab, same approve/dismiss flow as initials and replies.\n\n**You never send anything.** And you never chase someone who answered: the moment any inbound message lands on the conversation, the sequence is permanently stopped and `skill://outreach/draft-reply-email` owns the thread. `outreach_propose_followup` enforces this server-side — if it refuses, move on.\n\n## TL;DR\n\n1. **List due work** with `outreach_list_due_followups({})`. Every returned row is due *now* and pre-filtered — eligibility is not your job. An empty list means the pass is done; stop.\n2. **For each row**: read the thread with `conv_get_conversation(row.conversationId)` and the campaign with `outreach_get_campaign(row.campaignId)` for the campaign brief.\n3. **Draft** 30–90 words per the row's `stepBrief`, referencing the earlier email without repeating it. No subject, no unsubscribe footer — follow-ups thread inside the existing email chain, which already carries both.\n4. **File** with `outreach_propose_followup({ conversationId, step: row.nextStep, draftBody, evidence })`.\n5. **Stop** when every row is filed. No approving, no sending.\n\n## Step 1 — list due follow-ups\n\n```jsonc\n{ \"name\": \"outreach_list_due_followups\", \"arguments\": {} }\n```\n\nEach row carries `campaignId`, `campaignName`, `contactId`, `conversationId`, `nextStep` (1-based index into the campaign's `sequenceSteps`), `stepBrief`, `waitDays`, and `lastSentAt`. The server has already excluded: replied conversations, suppressed/unconsented contacts, disabled campaigns, closed or human-assigned conversations, contacts with a pending follow-up or reply draft, sequences stopped by a dismissed step, and contacts held back by the campaign's cadence rules (`maxPerWeekPerContact` budget over the trailing 7 days, `blackoutDates`). Do not re-derive any of that — draft what you're given.\n\n## Step 2 — read the thread and campaign\n\n```jsonc\n{ \"name\": \"conv_get_conversation\", \"arguments\": { \"id\": \"ccv_…\" } }\n{ \"name\": \"outreach_get_campaign\", \"arguments\": { \"id\": \"ocmp_…\" } }\n```\n\nRead what was already sent — the follow-up must not repeat the initial's pitch. The campaign `brief` gives the overall goal; the row's `stepBrief` gives this step's specific angle. When the two pull apart, the step brief wins: step 3 of a sequence is often deliberately different in tone (\"breakup email\") from the campaign's opening pitch.\n\n## Step 3 — draft\n\n- **30–90 words.** Follow-ups are shorter than initials. The prospect has already seen the pitch; this is a nudge, not a re-send.\n- **Per the step brief.** \"Gentle bump\" is two sentences. \"Share a case study\" earns a `kb_search` to ground a concrete, real example — don't fabricate customers or numbers. \"Breakup email\" is graceful: one line closing the loop, door left open, zero guilt.\n- **Reference, don't repeat.** \"Wanted to make sure my note last week didn't get buried\" beats restating the offer.\n- **Plain prose.** No headings, no bullet lists, no JSON-escaping.\n- **No subject, no unsubscribe footer.** The thread already carries both; the send path ignores subjects on follow-ups anyway.\n- **Never guilt-trip.** \"I know you're busy…\" once is human; twice is passive-aggressive. If the step brief smells like pressure, draft the polite version.\n\n## Step 4 — file the proposal\n\n```jsonc\n{\n \"name\": \"outreach_propose_followup\",\n \"arguments\": {\n \"conversationId\": \"ccv_…\",\n \"step\": 2,\n \"draftBody\": \"Hi Jane,\\n\\nCircling back in case my last note got buried — Acme cut their support backlog 40% in the first month with the setup I described (happy to share how). Worth a 15-minute look this week?\\n\\n— Munin\",\n \"evidence\": {\n \"stepBrief\": \"share a relevant case study\",\n \"kbDocIds\": [\"kdoc_acme_case_study\"],\n \"reasoning\": \"No reply 7 days after initial; step 2 calls for social proof; Acme case study matches their industry.\"\n }\n }\n}\n```\n\nBehavior:\n\n- The proposal lands in `pending` status, visible to the operator on `/dashboard/inbox` with a Follow-up badge and step number.\n- Approving sends the body verbatim via `conv_send_message` on the same conversation. If the prospect replied in the meantime, approval fails and the operator dismisses — the reply flow has taken over.\n- **Dismissing a follow-up permanently stops the sequence for that contact.** Operators who dislike the wording should edit-then-approve instead. Write drafts worth editing, not dismissing.\n- **Withdrawing one does not stop the sequence.** If *you* filed a follow-up you shouldn't have, `outreach_withdraw_proposal({ id, reason })` retracts it and leaves the step eligible again — that is the difference from dismiss, and the reason to never use dismiss to tidy up your own drafts.\n\n## What NOT to do\n\n- **Don't auto-send.** No `conv_send_message` from this skill. Drafts only.\n- **Don't draft for conversations the tool refuses.** A refusal means a reply landed, the step was dismissed, or a draft is already queued — all of them mean \"not yours\". Never work around it.\n- **Don't skip ahead.** `step` must be exactly `row.nextStep`; the service rejects out-of-order steps.\n- **Don't re-pitch.** The initial made the case. Follow-ups add one new thing (proof, angle, deadline) or simply nudge.\n- **Don't stack asks.** One follow-up, one call to action.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — drafts the first touch this sequence follows.\n- `skill://outreach/draft-reply-email` — takes over the conversation the moment the prospect replies; an inbound reply permanently stops this sequence.\n- `skill://outreach/review-proposals` — the operator flow that approves or dismisses these drafts.\n" }, + { + "uri": "skill://outreach/draft-initial-call", + "module": "outreach", + "slug": "draft-initial-call", + "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" + }, { "uri": "skill://outreach/draft-initial-email", "module": "outreach", @@ -303,7 +312,7 @@ "title": "Outreach: Draft an initial email", "description": "Periodic curator pass that drafts personalised first-touch outreach emails for every enabled campaign. One pending proposal per (campaign, contact). Drafts go into the operator review queue — never auto-send. Runs weekly by default; the operator approves each draft before it leaves the org.", "mimeType": "text/markdown", - "content": "# Draft an initial outreach email\nOperators set up campaigns (`outreach_create_campaign`) with a one-paragraph **brief** and a target **CRM segment**. Your job in a pass is to materialise the segment, draft a personalised first-touch email per contact, and file each draft as a pending **proposal** for human review. **You never send anything.** The operator approves each proposal one by one (or a trusted admin agent does on their behalf), at which point the system sends via the campaign's email channel and threads any reply back into the same conversation.\n\nThis pass is symmetric with `skill://kb/review-content` (drafted candidates) but for outreach instead of KB. Always-propose is non-negotiable: an LLM-drafted cold email going straight to a prospect is exactly how you ship a tone-deaf message you can't take back. Human approval is the system invariant.\n\nA separate `skill://crm/clean-contact-data` runs weekly to merge any duplicate contacts this and other curators leave behind. Don't try to do hygiene's job here — keep the per-campaign pass narrow.\n\n## TL;DR\n\n1. **List campaigns** with `outreach_list_campaigns`. Skip rows where `enabled = false` or `autoDraftInitial = false` (the latter are drafted manually on demand, not by this weekly pass).\n2. **For each campaign**, materialise the audience with `crm_list_contacts_in_segment(campaign.segmentId)`. The list is *already* filtered for suppression (`do_not_contact`, `unsubscribed_at`) and lawful basis (`consent_lawful_basis IS NOT NULL`) — that floor is non-overridable in the service. Treat what comes back as the eligible set.\n3. **For each contact in the audience**, dedupe via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })`. Skip if any proposal is `pending`, `approved`, or `sent` (already drafted or already reached). Only `dismissed`/`withdrawn`/`failed` allow a re-draft.\n4. **Pull product context** with `kb_search` against the brief — find 1–3 relevant KB snippets to ground the email in real facts (don't fabricate features).\n5. **Draft** an 80–200-word email, personalised to the contact's name + company. Plain prose, no headings, sparing bold/italic, no JSON-escaping. The unsubscribe footer is appended **at approve-time** by the system — do not include one in your draft.\n6. **File** with `outreach_propose_initial({ campaignId, contactId, draftSubject, draftBody, evidence })`. The `evidence` JSONB carries the (KB doc ids, contact-tag matches, reasoning summary) you'd want a human reviewer to see — keep it short and structured.\n7. **Stop.** No further calls. The operator's approval flow does the sending.\n\n## Step 1 — list enabled campaigns\n\n```jsonc\n{ \"name\": \"outreach_list_campaigns\", \"arguments\": {} }\n```\n\nEach row carries `id`, `name`, `brief`, `segmentId`, `channelId`, `cadenceRules`, `ctaUrl`, `enabled`, `autoDraftInitial`, `autoDraftReplies`, `unsubscribeRequired`. Filter to `enabled = true` AND `autoDraftInitial = true` (a campaign with `autoDraftInitial = false` is live but the operator drafts first-touch by hand — leave it alone). Skim `cadenceRules.maxPerWeekPerContact` for sanity (it doesn't gate you here — it's enforced at send-time — but if you see `1` you should be especially conservative about re-running too often).\n\n## Step 2 — materialise the audience\n\n```jsonc\n{ \"name\": \"crm_list_contacts_in_segment\", \"arguments\": { \"id\": \"\", \"limit\": 200 } }\n```\n\nYou get `ContactDto[]` already pre-filtered. Each contact has `id`, `name`, `email`, `companyId`, `tags`, `consentLawfulBasis`, `lastContactedAt`, etc. **Do not** call `crm_list_contacts` directly — that surface bypasses the suppression+consent floor.\n\nIf the segment returns 0 contacts, skip this campaign entirely.\n\n## Step 3 — dedupe before drafting\n\n```jsonc\n{\n \"name\": \"outreach_list_proposals\",\n \"arguments\": { \"kind\": \"initial\", \"campaignId\": \"\", \"contactId\": \"\" }\n}\n```\n\nIf any returned proposal is `pending`, `approved`, or `sent`, skip the contact — they already have a draft in flight or were already reached. Don't re-propose; the service will reject you anyway (the pending unique index for a pending draft, an `outreach_conflict` for a sent/approved first-touch), and you'll waste an LLM call. Three statuses leave the contact eligible for a fresh draft: `dismissed` (the operator rejected a prior draft), `withdrawn` (a curator retracted its own draft), and `failed` (a send that didn't land).\n\nYou may also want to skip when the contact's `lastContactedAt` was within `cadenceRules.maxPerWeekPerContact / 7` days — but for the initial pass, skipping based on an existing non-dismissed proposal is the only hard rule.\n\n**If you catch your own mistake after filing**, don't leave it in the queue for a human to clean up. `outreach_withdraw_proposal({ id, reason })` retracts a pending draft neutrally — the case this exists for is exactly the one this pass produces: two drafts for the same person, a contact who turns out not to fit the segment's intent, an address you later learn bounces. It doesn't suppress the contact or stop anything; it just takes the draft back. If the recipient is right and only the wording is wrong, revise in place instead — see `skill://outreach/review-proposals`.\n\n## Step 4 — pull product context\n\nThe campaign's brief is operator-written intent (\"we just shipped a feature for X-shaped customers\"). Don't paraphrase claims you can't ground. Use `kb_search` to pull supporting docs:\n\n```jsonc\n{ \"name\": \"kb_search\", \"arguments\": { \"query\": \"\", \"limit\": 3 } }\n```\n\nIf `kb_search` returns nothing relevant, your draft must rely strictly on the brief — don't invent features or numbers. If the brief itself is a thin prompt and there's no KB grounding, write the email at a higher level (\"we'd like to learn how you're approaching X\" rather than \"we ship X feature with Y latency\").\n\n## Step 5 — draft\n\nStrict rules:\n\n- **Subject** — concrete and specific. 6–12 words. No clickbait, no all-caps. Avoid generic openers (\"Quick question?\"); reference the brief or the contact's company.\n- **Body** — 80–200 words. Personalisation is one short sentence at most (\"saw you're at Acme — congrats on the recent funding\" only if you can ground it in evidence; otherwise drop it). The rest is brief, the value prop, one direct ask.\n- **Format** — plain prose. Bold/italic sparingly for one or two key terms. Bullets are OK for a list of 2–3 short items. **No `#`/`##`/`###` headings.** No tables, no images.\n- **JSON literals** — pass real strings with real newlines. Do not stringify the body so it ends up containing `\\n` characters.\n- **Voice** — second person, plain language, the way an operator would write if they had time.\n- **Unsubscribe footer** — do **NOT** include one. The system appends a signed unsubscribe link at approve-time so it can't be tampered with at draft-time.\n\n## Step 6 — file the proposal\n\n```jsonc\n{\n \"name\": \"outreach_propose_initial\",\n \"arguments\": {\n \"campaignId\": \"ocmp_…\",\n \"contactId\": \"cct_…\",\n \"draftSubject\": \"Quick thought on Acme's onboarding loop\",\n \"draftBody\": \"Hi Jane,\\n\\nI noticed Acme just shipped self-serve onboarding — congrats. We help similar B2B teams cut time-to-first-value by ~40% by …\",\n \"evidence\": {\n \"kbDocIds\": [\"kdoc_abc\", \"kdoc_def\"],\n \"contactSignals\": [\"title=Head of Ops\", \"tag=enterprise\"],\n \"reasoning\": \"Brief targets ops leaders; contact title matches; one KB doc on onboarding loops.\"\n }\n }\n}\n```\n\nBehavior:\n\n- The proposal lands in `pending` status, visible to the operator on `/dashboard/inbox` (Outreach drafts tab).\n- An `outreach.proposal.created` realtime event fires.\n- Re-running this skill on the same (campaign, contact) while a pending draft exists, or after a first-touch was already sent/approved, will reject with a conflict — that's the dedup signal.\n\n## Step 7 — review and approve (the operator's loop)\n\nOut of scope for this skill — see `skill://outreach/review-proposals`. The operator (or a trusted admin agent acting on their authority) calls `outreach_list_proposals({ status: \"pending\" })`, reviews each draft (MCP App hosts render the Munin Inspector review panel inline), then either approves via `outreach_approve_proposal` (which sends via the campaign's email channel and creates an outbound conversation) or dismisses via `outreach_dismiss_proposal` with a reason.\n\n## What NOT to do\n\n- **Don't auto-approve.** The plan-level invariant: every outreach email ships through a human-approved gate. If you're tempted to call `outreach_propose_initial` followed by `outreach_approve_proposal`, stop. The approve surface belongs to the operator's review pass (`skill://outreach/review-proposals`); a curator never decides its own drafts.\n- **Don't bypass `crm_list_contacts_in_segment`.** Calling `crm_list_contacts` directly bypasses the suppression+consent floor and will eventually file proposals for someone who already unsubscribed — even if the operator catches it at approve-time, the audit trail looks bad.\n- **Don't fabricate facts.** If the brief says \"we shipped feature X\" and KB has no doc on X, write at a higher level. Better to send a vaguer email than a confidently wrong one.\n- **Don't write headings or pseudo-templates.** No `# Hello {name}` or `## About us`. Real emails are plain prose.\n- **Don't include an unsubscribe link in the draft body.** The system appends one. If you write your own, the operator will see two and the system one is the only signed/verifiable one.\n- **Don't propose a reply.** PR3 ships `outreach_propose_reply` and a separate skill (`skill://outreach/draft-reply-email`). For now, you only file `kind: \"initial\"`.\n\n## Related\n\n- `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.\n- `skill://kb/review-content` — symmetric pattern (per-conversation curator that proposes, human approves) for KB instead of outreach.\n- `skill://crm/clean-contact-data` — population-level dedup that catches duplicates this and other curators create.\n- `skill://crm/extract-contact-from-message` — auto-applied (NOT propose-and-review) per-conversation contact creation. The asymmetry vs this skill: extracting what the user typed is faithful transcription; drafting outreach is generative — different risk profiles.\n" + "content": "# Draft an initial outreach email\nOperators set up campaigns (`outreach_create_campaign`) with a one-paragraph **brief** and a target **CRM segment**. Your job in a pass is to materialise the segment, draft a personalised first-touch email per contact, and file each draft as a pending **proposal** for human review. **You never send anything.** The operator approves each proposal one by one (or a trusted admin agent does on their behalf), at which point the system sends via the campaign's email channel and threads any reply back into the same conversation.\n\nThis pass is symmetric with `skill://kb/review-content` (drafted candidates) but for outreach instead of KB. Always-propose is non-negotiable: an LLM-drafted cold email going straight to a prospect is exactly how you ship a tone-deaf message you can't take back. Human approval is the system invariant.\n\nA separate `skill://crm/clean-contact-data` runs weekly to merge any duplicate contacts this and other curators leave behind. Don't try to do hygiene's job here — keep the per-campaign pass narrow.\n\n## TL;DR\n\n1. **List campaigns** with `outreach_list_campaigns`. Skip rows where `enabled = false` or `autoDraftInitial = false` (the latter are drafted manually on demand, not by this weekly pass).\n2. **For each campaign**, materialise the audience with `crm_list_contacts_in_segment(campaign.segmentId)`. The list is *already* filtered for suppression (`do_not_contact`, `unsubscribed_at`) and lawful basis (`consent_lawful_basis IS NOT NULL`) — that floor is non-overridable in the service. Treat what comes back as the eligible set.\n3. **For each contact in the audience**, dedupe via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })`. Skip if any proposal is `pending`, `approved`, or `sent` (already drafted or already reached). Only `dismissed`/`withdrawn`/`failed` allow a re-draft.\n4. **Pull product context** with `kb_search` against the brief — find 1–3 relevant KB snippets to ground the email in real facts (don't fabricate features).\n5. **Draft** an 80–200-word email, personalised to the contact's name + company. Plain prose, no headings, sparing bold/italic, no JSON-escaping. The unsubscribe footer is appended **at approve-time** by the system — do not include one in your draft.\n6. **File** with `outreach_propose_initial({ campaignId, contactId, draftSubject, draftBody, evidence })`. The `evidence` JSONB carries the (KB doc ids, contact-tag matches, reasoning summary) you'd want a human reviewer to see — keep it short and structured.\n7. **Stop.** No further calls. The operator's approval flow does the sending.\n\n## Step 1 — list enabled campaigns\n\n```jsonc\n{ \"name\": \"outreach_list_campaigns\", \"arguments\": {} }\n```\n\nEach row carries `id`, `name`, `brief`, `segmentId`, `channelId`, `cadenceRules`, `ctaUrl`, `enabled`, `autoDraftInitial`, `autoDraftReplies`, `unsubscribeRequired`. Filter to `enabled = true` AND `autoDraftInitial = true` (a campaign with `autoDraftInitial = false` is live but the operator drafts first-touch by hand — leave it alone). Skim `cadenceRules.maxPerWeekPerContact` for sanity (it doesn't gate you here — it's enforced at send-time — but if you see `1` you should be especially conservative about re-running too often).\n\n## Step 2 — materialise the audience\n\n```jsonc\n{ \"name\": \"crm_list_contacts_in_segment\", \"arguments\": { \"id\": \"\", \"limit\": 200 } }\n```\n\nYou get `ContactDto[]` already pre-filtered. Each contact has `id`, `name`, `email`, `companyId`, `tags`, `consentLawfulBasis`, `lastContactedAt`, etc. **Do not** call `crm_list_contacts` directly — that surface bypasses the suppression+consent floor.\n\nIf the segment returns 0 contacts, skip this campaign entirely.\n\n## Step 3 — dedupe before drafting\n\n```jsonc\n{\n \"name\": \"outreach_list_proposals\",\n \"arguments\": { \"kind\": \"initial\", \"campaignId\": \"\", \"contactId\": \"\" }\n}\n```\n\nIf any returned proposal is `pending`, `approved`, or `sent`, skip the contact — they already have a draft in flight or were already reached. Don't re-propose; the service will reject you anyway (the pending unique index for a pending draft, an `outreach_conflict` for a sent/approved first-touch), and you'll waste an LLM call. Three statuses leave the contact eligible for a fresh draft: `dismissed` (the operator rejected a prior draft), `withdrawn` (a curator retracted its own draft), and `failed` (a send that didn't land).\n\nYou may also want to skip when the contact's `lastContactedAt` was within `cadenceRules.maxPerWeekPerContact / 7` days — but for the initial pass, skipping based on an existing non-dismissed proposal is the only hard rule.\n\n**If you catch your own mistake after filing**, don't leave it in the queue for a human to clean up. `outreach_withdraw_proposal({ id, reason })` retracts a pending draft neutrally — the case this exists for is exactly the one this pass produces: two drafts for the same person, a contact who turns out not to fit the segment's intent, an address you later learn bounces. It doesn't suppress the contact or stop anything; it just takes the draft back. If the recipient is right and only the wording is wrong, revise in place instead — see `skill://outreach/review-proposals`.\n\n## Step 4 — pull product context\n\nThe campaign's brief is operator-written intent (\"we just shipped a feature for X-shaped customers\"). Don't paraphrase claims you can't ground. Use `kb_search` to pull supporting docs:\n\n```jsonc\n{ \"name\": \"kb_search\", \"arguments\": { \"query\": \"\", \"limit\": 3 } }\n```\n\nIf `kb_search` returns nothing relevant, your draft must rely strictly on the brief — don't invent features or numbers. If the brief itself is a thin prompt and there's no KB grounding, write the email at a higher level (\"we'd like to learn how you're approaching X\" rather than \"we ship X feature with Y latency\").\n\n## Step 5 — draft\n\nStrict rules:\n\n- **Subject** — concrete and specific. 6–12 words. No clickbait, no all-caps. Avoid generic openers (\"Quick question?\"); reference the brief or the contact's company.\n- **Body** — 80–200 words. Personalisation is one short sentence at most (\"saw you're at Acme — congrats on the recent funding\" only if you can ground it in evidence; otherwise drop it). The rest is brief, the value prop, one direct ask.\n- **Format** — plain prose. Bold/italic sparingly for one or two key terms. Bullets are OK for a list of 2–3 short items. **No `#`/`##`/`###` headings.** No tables, no images.\n- **JSON literals** — pass real strings with real newlines. Do not stringify the body so it ends up containing `\\n` characters.\n- **Voice** — second person, plain language, the way an operator would write if they had time.\n- **Unsubscribe footer** — do **NOT** include one. The system appends a signed unsubscribe link at approve-time so it can't be tampered with at draft-time.\n\n## Step 6 — file the proposal\n\n```jsonc\n{\n \"name\": \"outreach_propose_initial\",\n \"arguments\": {\n \"campaignId\": \"ocmp_…\",\n \"contactId\": \"cct_…\",\n \"draftSubject\": \"Quick thought on Acme's onboarding loop\",\n \"draftBody\": \"Hi Jane,\\n\\nI noticed Acme just shipped self-serve onboarding — congrats. We help similar B2B teams cut time-to-first-value by ~40% by …\",\n \"evidence\": {\n \"kbDocIds\": [\"kdoc_abc\", \"kdoc_def\"],\n \"contactSignals\": [\"title=Head of Ops\", \"tag=enterprise\"],\n \"reasoning\": \"Brief targets ops leaders; contact title matches; one KB doc on onboarding loops.\"\n }\n }\n}\n```\n\nBehavior:\n\n- The proposal lands in `pending` status, visible to the operator on `/dashboard/inbox` (Outreach drafts tab).\n- An `outreach.proposal.created` realtime event fires.\n- Re-running this skill on the same (campaign, contact) while a pending draft exists, or after a first-touch was already sent/approved, will reject with a conflict — that's the dedup signal.\n\n## Step 7 — review and approve (the operator's loop)\n\nOut of scope for this skill — see `skill://outreach/review-proposals`. The operator (or a trusted admin agent acting on their authority) calls `outreach_list_proposals({ status: \"pending\" })`, reviews each draft (MCP App hosts render the Munin Inspector review panel inline), then either approves via `outreach_approve_proposal` (which sends via the campaign's email channel and creates an outbound conversation) or dismisses via `outreach_dismiss_proposal` with a reason.\n\n## What NOT to do\n\n- **Don't auto-approve.** The plan-level invariant: every outreach email ships through a human-approved gate. If you're tempted to call `outreach_propose_initial` followed by `outreach_approve_proposal`, stop. The approve surface belongs to the operator's review pass (`skill://outreach/review-proposals`); a curator never decides its own drafts.\n- **Don't bypass `crm_list_contacts_in_segment`.** Calling `crm_list_contacts` directly bypasses the suppression+consent floor and will eventually file proposals for someone who already unsubscribed — even if the operator catches it at approve-time, the audit trail looks bad.\n- **Don't fabricate facts.** If the brief says \"we shipped feature X\" and KB has no doc on X, write at a higher level. Better to send a vaguer email than a confidently wrong one.\n- **Don't write headings or pseudo-templates.** No `# Hello {name}` or `## About us`. Real emails are plain prose.\n- **Don't include an unsubscribe link in the draft body.** The system appends one. If you write your own, the operator will see two and the system one is the only signed/verifiable one.\n- **Don't propose a reply.** PR3 ships `outreach_propose_reply` and a separate skill (`skill://outreach/draft-reply-email`). For now, you only file `kind: \"initial\"`.\n\n## Related\n\n- `skill://outreach/draft-initial-sms` — the same pass on an SMS campaign, capped at 480 characters of plain text.\n- `skill://outreach/draft-initial-call` — the same pass on a voice campaign, where you draft what an AI agent says when the call connects.\n\n- `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.\n- `skill://kb/review-content` — symmetric pattern (per-conversation curator that proposes, human approves) for KB instead of outreach.\n- `skill://crm/clean-contact-data` — population-level dedup that catches duplicates this and other curators create.\n- `skill://crm/extract-contact-from-message` — auto-applied (NOT propose-and-review) per-conversation contact creation. The asymmetry vs this skill: extracting what the user typed is faithful transcription; drafting outreach is generative — different risk profiles.\n" }, { "uri": "skill://outreach/draft-initial-sms", @@ -312,7 +321,7 @@ "title": "Outreach: Draft an initial text message", "description": "Draft first-touch SMS outreach for a campaign running on an SMS channel. One pending proposal per (campaign, contact), capped at 480 characters, plain text. Only a signed-in person in the Munin dashboard can approve a text — an agent never sends one.", "mimeType": "text/markdown", - "content": "# Draft an initial text message\n\nSame shape as `skill://outreach/draft-initial-email`, with three differences that matter enough to be their own skill: a text is far shorter, it costs money per segment, and **you cannot send one**.\n\n**Approving a text is a dashboard-only action.** `outreach_approve_proposal` refuses any caller that is not a signed-in dashboard user — agents, admin API keys, the Slack button. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. Don't retry, don't look for another tool, and don't ask for a credential that would work. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all still work on these — none of them send anything.\n\n## The pass\n\n1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Check the campaign's channel is SMS before drafting text-shaped copy — `outreach_list_proposals` on an existing proposal shows `delivery.channelType`, or read the channel off `conv_list_channels`.\n2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis. Anyone who ever replied `STOP` to a text is suppressed automatically and will not appear.\n3. **Skip contacts with no `phone`.** `outreach_propose_initial` rejects them, and a rejection you could have predicted is a wasted call.\n4. **Dedupe** via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })` exactly as the email pass does.\n5. **Draft** (rules below) and file with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a text has no subject.\n6. **Stop.**\n\n## Writing the text\n\n- **480 characters hard cap**, enforced by the service. That is roughly three billable segments; a single segment is 160 characters of GSM-7 (70 if you use emoji or characters outside the GSM alphabet, which silently switches the whole message to UCS-2 and triples the cost). Aim for one segment. Every character is someone's money.\n- **Plain text only.** No markdown — `**bold**` and `[link](url)` arrive literally as asterisks and brackets. Write the URL bare if you need one.\n- **Do not write an opt-out line.** Munin appends `Reply STOP to opt out.` at approve time when the campaign requires it, and appends the campaign CTA URL if one is set. Both are counted against the recipient's screen, not yours, so leave room.\n- **Say who you are in the first clause.** A text from an unknown number with no sender is indistinguishable from spam, and unlike email there is no From line to check.\n- **One ask.** There is no room for context-setting, a value proposition and a call to action. Pick the ask.\n- **Match the recipient's language.** A Norwegian contact gets Norwegian; don't send translated English.\n\nGood: `Hei Jane — Kjell fra Munin. Du ba om beskjed når vi lanserte selvbetjent onboarding. Den er live nå. Vil du ha en rask demo?`\n\nBad: `Hi Jane! 👋 **Great news** from the team at Munin — we've *just* shipped our new self-serve onboarding flow, which we think you'll love based on our last conversation. Check it out here: [Munin onboarding](https://…) and let us know what you think! Reply STOP to unsubscribe.` — emoji forces UCS-2, markdown arrives raw, and it hand-writes an opt-out line the system will append again.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin composes the final body (draft + CTA + opt-out line), creates an outbound conversation on the campaign's SMS channel in `draft_only` mode, and queues the message for delivery. A reply from the prospect threads into that same conversation, so `outreach_propose_reply` works there — see `skill://outreach/draft-reply-email`, which applies to texts too.\n\nFollow-up sequences are email-only. An SMS campaign cannot carry `sequenceSteps`, and `outreach_propose_followup` rejects a text conversation. One touch, then the reply flow.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass for email, where you may draft at length.\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 number, including `defaultAgentMode` and how STOP suppression works.\n" + "content": "# Draft an initial text message\n\nSame shape as `skill://outreach/draft-initial-email`, with three differences that matter enough to be their own skill: a text is far shorter, it costs money per segment, and **you cannot send one**.\n\n**Approving a text is a dashboard-only action.** `outreach_approve_proposal` refuses any caller that is not a signed-in dashboard user — agents, admin API keys, the Slack button. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. Don't retry, don't look for another tool, and don't ask for a credential that would work. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all still work on these — none of them send anything.\n\n## The pass\n\n1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Check the campaign's channel is SMS before drafting text-shaped copy — `outreach_list_proposals` on an existing proposal shows `delivery.channelType`, or read the channel off `conv_list_channels`.\n2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis. Anyone who ever replied `STOP` to a text is suppressed automatically and will not appear.\n3. **Skip contacts with no `phone`.** `outreach_propose_initial` rejects them, and a rejection you could have predicted is a wasted call.\n4. **Dedupe** via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })` exactly as the email pass does.\n5. **Draft** (rules below) and file with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a text has no subject.\n6. **Stop.**\n\n## Writing the text\n\n- **480 characters hard cap**, enforced by the service. That is roughly three billable segments; a single segment is 160 characters of GSM-7 (70 if you use emoji or characters outside the GSM alphabet, which silently switches the whole message to UCS-2 and triples the cost). Aim for one segment. Every character is someone's money.\n- **Plain text only.** No markdown — `**bold**` and `[link](url)` arrive literally as asterisks and brackets. Write the URL bare if you need one.\n- **Do not write an opt-out line.** Munin appends `Reply STOP to opt out.` at approve time when the campaign requires it, and appends the campaign CTA URL if one is set. Both are counted against the recipient's screen, not yours, so leave room.\n- **Say who you are in the first clause.** A text from an unknown number with no sender is indistinguishable from spam, and unlike email there is no From line to check.\n- **One ask.** There is no room for context-setting, a value proposition and a call to action. Pick the ask.\n- **Match the recipient's language.** A Norwegian contact gets Norwegian; don't send translated English.\n\nGood: `Hei Jane — Kjell fra Munin. Du ba om beskjed når vi lanserte selvbetjent onboarding. Den er live nå. Vil du ha en rask demo?`\n\nBad: `Hi Jane! 👋 **Great news** from the team at Munin — we've *just* shipped our new self-serve onboarding flow, which we think you'll love based on our last conversation. Check it out here: [Munin onboarding](https://…) and let us know what you think! Reply STOP to unsubscribe.` — emoji forces UCS-2, markdown arrives raw, and it hand-writes an opt-out line the system will append again.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin composes the final body (draft + CTA + opt-out line), creates an outbound conversation on the campaign's SMS channel in `draft_only` mode, and queues the message for delivery. A reply from the prospect threads into that same conversation, so `outreach_propose_reply` works there — see `skill://outreach/draft-reply-email`, which applies to texts too.\n\nFollow-up sequences are email-only. An SMS campaign cannot carry `sequenceSteps`, and `outreach_propose_followup` rejects a text conversation. One touch, then the reply flow.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass for email, where you may draft at length.\n- `skill://outreach/draft-initial-call` — the spoken equivalent, with a higher bar for who is worth contacting.\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 number, including `defaultAgentMode` and how STOP suppression works.\n" }, { "uri": "skill://outreach/draft-reply-email", @@ -330,7 +339,7 @@ "title": "Outreach: Review pending proposals", "description": "Operator review pass over drafted outreach proposals — approve (which sends) or dismiss each pending draft, and the two agent-side corrections, revise and withdraw. Voice and SMS proposals are approved only in the Munin dashboard. In MCP App hosts this renders the interactive Munin Inspector panel.", "mimeType": "text/markdown", - "content": "# Review pending outreach proposals\n\nEvery 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.\n\n**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.\n\n**Approving sends.** `outreach_approve_proposal` is not a status flip: for an `initial` proposal it creates the outbound conversation and sends the first email through the campaign's channel (appending the CTA link and unsubscribe footer per campaign settings); for a `reply` or `followup` it sends the draft verbatim on the existing conversation. There is no undo. Never approve in bulk without reading each draft.\n\n**Dismissing a follow-up stops the sequence.** A dismissed `followup` permanently ends the campaign's follow-up sequence for that contact — no later step will be drafted. That makes dismiss the right call for \"stop chasing this person\" and the wrong call for \"reword this\". For wording, revise the draft in place (below) or edit it in the dashboard review drawer, then approve the edited version.\n\n## Four verbs, four different meanings\n\n| Tool | Who it's for | What it means |\n|---|---|---|\n| `outreach_approve_proposal` | operator | Send it. No undo. Email only — voice and SMS are dashboard-only. |\n| `outreach_dismiss_proposal` | operator | *Rejected.* A judgement about this draft; on a `followup` it also stops the sequence. |\n| `outreach_revise_proposal` | agent | Same proposal, better text. Recipient and campaign are fixed. |\n| `outreach_withdraw_proposal` | agent | *Never mind* — the draft should not have been filed. Neutral. |\n\nDismiss is a decision about the draft; withdraw is the agent admitting the draft was a mistake. Don't reach for dismiss to clean up after yourself, and don't withdraw a draft an operator asked you to reject — the reasons land in different fields and read differently in the audit trail.\n\n## Revising a pending draft\n\n`outreach_revise_proposal({ \"id\": \"...\", \"reason\": \"...\", \"draftBody\": \"...\" })` rewrites the draft in place. The proposal id, the contact, and the campaign do not change — a different recipient is a different proposal, so file a new one instead. `draftSubject` and `proposedSendAt` can be revised the same way, and `reason` is required.\n\nThe revision is recorded, not silent. Each call bumps `revisionCount` and stamps `lastRevisedAt`, `lastRevisionReason`, and the revising actor. If somebody else had already opened the draft for review before your change, `revisedAfterReviewAt` is stamped too and the review surfaces flag it — the operator who read Monday's text gets told, in the panel and in the dashboard drawer, that Wednesday's text is not what they read.\n\nThat flag is the point. **Never revise a draft an operator is mid-review on and then ask them to approve as if nothing changed.** If you revise after review, say so in the same breath you present it, and let them re-read the full body.\n\n## Withdrawing your own draft\n\n`outreach_withdraw_proposal({ \"id\": \"...\", \"reason\": \"...\" })` retracts a pending proposal you should not have filed: you drafted the same person twice, the prospect turned out not to qualify, the address bounced, the campaign premise no longer holds. Nothing is sent and `reason` is required.\n\nWithdrawal is deliberately neutral. It does **not** suppress the contact, does **not** touch their consent, and does **not** stop a campaign sequence — a withdrawn `followup` leaves that step eligible again, unlike a dismissed one. If the contact genuinely should never be contacted again, that is a suppression decision: `crm_set_contact_consent` / the do-not-contact flag, not a withdrawal.\n\nBecause withdrawal clears the pending slot for that (campaign, contact, kind), you can withdraw a bad draft and file a corrected one. Prefer `outreach_revise_proposal` when the recipient is right and only the text is wrong — withdraw-and-refile loses the review history.\n\n## In an MCP App host (Claude, Claude Desktop, …)\n\nCall `outreach_list_proposals({ \"status\": \"pending\" })`. Hosts that support MCP Apps render the **Munin Inspector** panel (`ui://munin/inspector`) inline: one card per proposal with the contact, campaign, draft subject/body, the curator's evidence, and a `delivery` line naming the address or phone number the approval would reach. In these hosts the decision tools are **panel-only** (`_meta.ui.visibility: [\"app\"]`): they are hidden from you and only the operator's click can invoke them. Render the list and stop — the send decision is physically the human's.\n\nEmail proposals get **Approve & send** and **Dismiss** buttons. Voice and SMS proposals get **Dismiss** only, plus a line pointing the operator at the dashboard — the panel cannot place a call, because the server refuses any approval that does not come from a signed-in dashboard session.\n\n## Without a panel\n\nIn hosts without MCP Apps support the decision tools appear as ordinary tools, and the same flow works as plain tool calls — for **email campaigns only**:\n\n1. **List** — `outreach_list_proposals({ \"status\": \"pending\" })`. Each row carries `id`, `kind`, `draftSubject`, `draftBody`, `evidence`, `proposedSendAt`, nested `contact` / `campaign` summaries, and `delivery`.\n2. **Read `delivery` first.** It names the channel and the exact destination. If `delivery.channelType` is `voice` or `sms`, there is nothing for you to approve: present the draft and the number, tell the operator it is waiting in the dashboard inbox, and move on. If `delivery.destination` is `null`, the contact has no address or number on file and approval would fail — say so instead of trying.\n3. **Present each draft** to the operator: who it goes to, which campaign, the subject, the full body, and anything notable in `evidence`. Don't paraphrase the body — the operator is approving the literal text. `delivery.appendsCta` / `appendsUnsubscribe` tell you what the system will add on top, so the operator isn't surprised by a footer they didn't read.\n4. **Decide one at a time** on the operator's word:\n - `outreach_approve_proposal({ \"id\": \"...\" })` — sends immediately; the result carries `status: \"sent\"`, `conversationId`, `sentMessageId`.\n - `outreach_dismiss_proposal({ \"id\": \"...\", \"reason\": \"...\" })` — no send; the reason lands on the proposal for the curator's next pass.\n5. **Handle refusals cleanly.** Both tools reject non-`pending` proposals (someone else may have decided it since listing — refresh rather than retry). Approval also rejects when the campaign was disabled, when the contact became suppressed since drafting, when the campaign is inside its quiet hours or on a blackout date, and — for `followup` proposals — when the prospect replied after the draft was filed (dismiss it; the reply flow owns the conversation now). That is the safety floor working, not an error to route around.\n\n## What not to do\n\n- **Never approve on your own initiative.** A pending queue is not permission. The invariant that makes propose-only outreach safe is that a human read each draft.\n- **Don't revise-and-approve in one breath.** A revision resets what the operator needs to read. Revise, re-present the full body, then wait for their word.\n- **Don't withdraw a draft to escape a refusal.** If approval failed because the contact was suppressed or the prospect replied, that is the safety floor doing its job — dismiss it (or leave it) rather than withdrawing to clear the slot and re-drafting around the block.\n- **Don't loop approve over the whole list** (\"approve all\") unless the operator explicitly reviewed every draft and said exactly that.\n- **Don't try to place a call.** If a voice or SMS approval is refused, that refusal is the product working as designed. There is no tool, key, or argument that makes it succeed — outbound calls and texts leave Munin only when a person clicks in the dashboard.\n" + "content": "# Review pending outreach proposals\n\nEvery 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.\n\n**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.\n\n**Approving sends.** `outreach_approve_proposal` is not a status flip: for an `initial` proposal it creates the outbound conversation and sends the first email through the campaign's channel (appending the CTA link and unsubscribe footer per campaign settings); for a `reply` or `followup` it sends the draft verbatim on the existing conversation. There is no undo. Never approve in bulk without reading each draft.\n\n**Dismissing a follow-up stops the sequence.** A dismissed `followup` permanently ends the campaign's follow-up sequence for that contact — no later step will be drafted. That makes dismiss the right call for \"stop chasing this person\" and the wrong call for \"reword this\". For wording, revise the draft in place (below) or edit it in the dashboard review drawer, then approve the edited version.\n\n## Four verbs, four different meanings\n\n| Tool | Who it's for | What it means |\n|---|---|---|\n| `outreach_approve_proposal` | operator | Send it. No undo. Email only — voice and SMS are dashboard-only. |\n| `outreach_dismiss_proposal` | operator | *Rejected.* A judgement about this draft; on a `followup` it also stops the sequence. |\n| `outreach_revise_proposal` | agent | Same proposal, better text. Recipient and campaign are fixed. |\n| `outreach_withdraw_proposal` | agent | *Never mind* — the draft should not have been filed. Neutral. |\n\nDismiss is a decision about the draft; withdraw is the agent admitting the draft was a mistake. Don't reach for dismiss to clean up after yourself, and don't withdraw a draft an operator asked you to reject — the reasons land in different fields and read differently in the audit trail.\n\n## Revising a pending draft\n\n`outreach_revise_proposal({ \"id\": \"...\", \"reason\": \"...\", \"draftBody\": \"...\" })` rewrites the draft in place. The proposal id, the contact, and the campaign do not change — a different recipient is a different proposal, so file a new one instead. `draftSubject` and `proposedSendAt` can be revised the same way, and `reason` is required.\n\nThe revision is recorded, not silent. Each call bumps `revisionCount` and stamps `lastRevisedAt`, `lastRevisionReason`, and the revising actor. If somebody else had already opened the draft for review before your change, `revisedAfterReviewAt` is stamped too and the review surfaces flag it — the operator who read Monday's text gets told, in the panel and in the dashboard drawer, that Wednesday's text is not what they read.\n\nThat flag is the point. **Never revise a draft an operator is mid-review on and then ask them to approve as if nothing changed.** If you revise after review, say so in the same breath you present it, and let them re-read the full body.\n\n## Withdrawing your own draft\n\n`outreach_withdraw_proposal({ \"id\": \"...\", \"reason\": \"...\" })` retracts a pending proposal you should not have filed: you drafted the same person twice, the prospect turned out not to qualify, the address bounced, the campaign premise no longer holds. Nothing is sent and `reason` is required.\n\nWithdrawal is deliberately neutral. It does **not** suppress the contact, does **not** touch their consent, and does **not** stop a campaign sequence — a withdrawn `followup` leaves that step eligible again, unlike a dismissed one. If the contact genuinely should never be contacted again, that is a suppression decision: `crm_set_contact_consent` / the do-not-contact flag, not a withdrawal.\n\nBecause withdrawal clears the pending slot for that (campaign, contact, kind), you can withdraw a bad draft and file a corrected one. Prefer `outreach_revise_proposal` when the recipient is right and only the text is wrong — withdraw-and-refile loses the review history.\n\n## In an MCP App host (Claude, Claude Desktop, …)\n\nCall `outreach_list_proposals({ \"status\": \"pending\" })`. Hosts that support MCP Apps render the **Munin Inspector** panel (`ui://munin/inspector`) inline: one card per proposal with the contact, campaign, draft subject/body, the curator's evidence, and a `delivery` line naming the address or phone number the approval would reach. In these hosts the decision tools are **panel-only** (`_meta.ui.visibility: [\"app\"]`): they are hidden from you and only the operator's click can invoke them. Render the list and stop — the send decision is physically the human's.\n\nEmail proposals get **Approve & send** and **Dismiss** buttons. Voice and SMS proposals get **Dismiss** only, plus a line pointing the operator at the dashboard — the panel cannot place a call, because the server refuses any approval that does not come from a signed-in dashboard session.\n\n## Without a panel\n\nIn hosts without MCP Apps support the decision tools appear as ordinary tools, and the same flow works as plain tool calls — for **email campaigns only**:\n\n1. **List** — `outreach_list_proposals({ \"status\": \"pending\" })`. Each row carries `id`, `kind`, `draftSubject`, `draftBody`, `evidence`, `proposedSendAt`, nested `contact` / `campaign` summaries, and `delivery`.\n2. **Read `delivery` first.** It names the channel and the exact destination. If `delivery.channelType` is `voice` or `sms`, there is nothing for you to approve: present the draft and the number, tell the operator it is waiting in the dashboard inbox, and move on. If `delivery.destination` is `null`, the contact has no address or number on file and approval would fail — say so instead of trying.\n3. **Present each draft** to the operator: who it goes to, which campaign, the subject, the full body, and anything notable in `evidence`. Don't paraphrase the body — the operator is approving the literal text. `delivery.appendsCta` / `appendsUnsubscribe` tell you what the system will add on top, so the operator isn't surprised by a footer they didn't read.\n4. **Decide one at a time** on the operator's word:\n - `outreach_approve_proposal({ \"id\": \"...\" })` — sends immediately; the result carries `status: \"sent\"`, `conversationId`, `sentMessageId`.\n - `outreach_dismiss_proposal({ \"id\": \"...\", \"reason\": \"...\" })` — no send; the reason lands on the proposal for the curator's next pass.\n5. **Handle refusals cleanly.** Both tools reject non-`pending` proposals (someone else may have decided it since listing — refresh rather than retry). Approval also rejects when the campaign was disabled, when the contact became suppressed since drafting, when the campaign is inside its quiet hours or on a blackout date, and — for `followup` proposals — when the prospect replied after the draft was filed (dismiss it; the reply flow owns the conversation now). That is the safety floor working, not an error to route around.\n\n## What not to do\n\n- **Never approve on your own initiative.** A pending queue is not permission. The invariant that makes propose-only outreach safe is that a human read each draft.\n- **Don't revise-and-approve in one breath.** A revision resets what the operator needs to read. Revise, re-present the full body, then wait for their word.\n- **Don't withdraw a draft to escape a refusal.** If approval failed because the contact was suppressed or the prospect replied, that is the safety floor doing its job — dismiss it (or leave it) rather than withdrawing to clear the slot and re-drafting around the block.\n- **Don't loop approve over the whole list** (\"approve all\") unless the operator explicitly reviewed every draft and said exactly that.\n- **Don't try to place a call.** If a voice or SMS approval is refused, that refusal is the product working as designed. There is no tool, key, or argument that makes it succeed — outbound calls and texts leave Munin only when a person clicks in the dashboard.\n" }, { "uri": "skill://playbooks/customer-acquisition", diff --git a/packages/backend-core/src/modules/outreach/skills/draft-initial-call.md b/packages/backend-core/src/modules/outreach/skills/draft-initial-call.md new file mode 100644 index 00000000..15a4a300 --- /dev/null +++ b/packages/backend-core/src/modules/outreach/skills/draft-initial-call.md @@ -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. diff --git a/packages/backend-core/src/modules/outreach/skills/draft-initial-email.md b/packages/backend-core/src/modules/outreach/skills/draft-initial-email.md index 31e2020a..4c835f73 100644 --- a/packages/backend-core/src/modules/outreach/skills/draft-initial-email.md +++ b/packages/backend-core/src/modules/outreach/skills/draft-initial-email.md @@ -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. diff --git a/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md b/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md index 97b62037..3f56d72e 100644 --- a/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md +++ b/packages/backend-core/src/modules/outreach/skills/draft-initial-sms.md @@ -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. diff --git a/packages/backend-core/src/modules/outreach/skills/review-proposals.md b/packages/backend-core/src/modules/outreach/skills/review-proposals.md index b0dd24a6..6f34cbd2 100644 --- a/packages/backend-core/src/modules/outreach/skills/review-proposals.md +++ b/packages/backend-core/src/modules/outreach/skills/review-proposals.md @@ -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.