diff --git a/.changeset/tool-naming-consistency.md b/.changeset/tool-naming-consistency.md new file mode 100644 index 00000000..1320246c --- /dev/null +++ b/.changeset/tool-naming-consistency.md @@ -0,0 +1,47 @@ +--- +'@getmunin/backend-core': minor +'@getmunin/dashboard-pages': minor +'@getmunin/docs-pages': minor +'@getmunin/inspector-app': minor +'@getmunin/types': minor +--- + +Rename MCP tools so a tool's name says which surface it acts on, and add a test that keeps names and titles in agreement + +The channel tools were the worst offenders: `conv_test_channel` and `conv_send_channel_test` sounded universal but only resolve vendors in the voice/SMS adapter registry, so calling either on an email channel failed with `unknown channel vendor 'smtp'`. Meanwhile `conv_create_channel` sounded universal but only handles email and chat. The unqualified names were the narrow ones, and three verbs (`create` / `setup` / `configure`) meant the same action across three channel families. Renames: + +- `conv_setup_email_channel` → `conv_configure_email_channel` +- `conv_send_email_test` → `conv_send_email_channel_test` +- `conv_widget_create_channel` → `conv_create_widget_channel` +- `conv_widget_update_channel` → `conv_update_widget_channel` +- `conv_widget_rotate_key` → `conv_rotate_widget_key` +- `conv_widget_rotate_identity_secret` → `conv_rotate_widget_identity_secret` +- `conv_configure_channel` → `conv_configure_voice_sms_channel` +- `conv_test_channel` → `conv_test_voice_sms_channel` +- `conv_send_channel_test` → `conv_send_voice_sms_channel_test` +- `conv_list_channel_vendors` → `conv_list_voice_sms_vendors` + +`conv_create_channel` now rejects `voice` and `sms` at the schema instead of asking the model not to pass them. Taking that path used to insert a row with `active: true`, no adapter binding and no credential link — a channel that looked configured and could not send. `conv_import` still accepts all four types, since importing historical conversations is not the same as provisioning transport. + +Elsewhere the same operation carried different verbs, and four singular/plural pairs were distinguished by one letter where the rest of the surface uses `get_X` / `list_Xs`: + +- `crm_find_contact` → `crm_lookup_contact` (matches the connector modules' `lookup` for keyed-by-email reads) +- `bookings_lookup_bookings` → `bookings_list_guest_bookings` +- `bookings_get_my_bookings` → `bookings_list_my_bookings` +- `commerce_lookup_orders` → `commerce_list_customer_orders` +- `commerce_get_my_orders` → `commerce_list_my_orders` +- `crm_change_stage` → `crm_change_deal_stage` +- `cms_search` → `cms_search_entries` +- `slack_test` → `slack_send_test_message` +- `kb_import_website_status` → `kb_get_website_import_status` +- `outreach_propose_initial` → `outreach_propose_initial_message` +- `analytics_get_traffic_by_source` → `analytics_list_traffic_sources` +- `feedback_get` → `feedback_get_item`, `feedback_create` → `feedback_create_item`, `feedback_list` → `feedback_list_pending_items`, `feedback_search` → `feedback_search_roadmap`, `feedback_vote` → `feedback_vote_on_roadmap_item` + +The `feedback_*` prefix covered two unrelated corpora with nothing in the names to say so: `feedback_list` reads the local outbox awaiting admin action, while `feedback_search` queries the public Munin roadmap. + +Descriptions that no longer matched behaviour are corrected. `outreach_propose_initial_message` and `outreach_approve_proposal` still described email-only sends after SMS and voice campaigns shipped; approving now documents an email, an SMS, or an outbound call, and `outreach_propose_followup` states that sequences are email-only. `conv_list_channels` claimed `voice` and `sms` were "reserved for upcoming adapters" — both have shipped. `conv_request_channel_credentials` is generically named and generically implemented but claimed to be email-only, so an agent holding a pending Twilio channel would have skipped the one tool that mints its credential link. + +Titles now restate their tool's name rather than drifting from it — they are what a host shows in a permission prompt. Analytics titles were noun phrases ("Top referrer hosts") where the rest of the surface is imperative, and the bookings titles said "Book a table" for a vendor-agnostic bookings contract that also serves non-restaurant venues. + +`tool-naming.test.ts` boots the registry and asserts, across every registered tool, that names carry a known module prefix, that titles start with that module's display prefix, that a title's leading word matches the verb in its name, that a title mentions the object its name acts on, and that no two tools share a title. It caught three tools this pass that the manual review missed: `conv_widget_rotate_key` and `conv_widget_rotate_identity_secret` kept the family-before-verb shape, `conv_request_callback` was titled "Place a phone call…" against a name that promises a request, and `analytics_export_config` was titled "Export trackers + visitor identities" — accurate about the payload, silent about the `config` its name promises. diff --git a/packages/backend-core/docs-fixtures/mcp-tools.json b/packages/backend-core/docs-fixtures/mcp-tools.json index 1849301d..5a70eeab 100644 --- a/packages/backend-core/docs-fixtures/mcp-tools.json +++ b/packages/backend-core/docs-fixtures/mcp-tools.json @@ -23,7 +23,7 @@ }, { "name": "analytics_export_config", - "title": "Analytics: Export trackers + visitor identities", + "title": "Analytics: Export tracker configuration", "description": "Export this org's analytics configuration — trackers and visitor-identity links — as a portable JSON payload. Low-volume, returned in one shot. Tracker identity-verification secrets are redacted (the ciphertext is useless on another server); the operator re-enters them after import. Pair with `analytics_export_events` (paginated) and feed both into `analytics_import` on another Munin server.", "audiences": [ "admin" @@ -601,7 +601,7 @@ }, { "name": "analytics_list_top_subjects", - "title": "Analytics: Top subjects by view count", + "title": "Analytics: List top subjects by view count", "description": "List the most-viewed subjects (CMS entries, landing pages, etc.) over a recent window. Use this to see what content is actually getting traffic. Filter by `subjectType` to scope to one surface (e.g. `cms_entry`). Pass `endUserId` or `contactId` to restrict the ranking to one identified visitor — useful for \"what has this lead been reading?\".", "audiences": [ "admin" @@ -655,7 +655,7 @@ }, { "name": "analytics_list_top_countries", - "title": "Analytics: Visitors by country", + "title": "Analytics: List top countries by visitors", "description": "Visitor and view counts grouped by ISO 3166-1 alpha-2 country code over a recent window. Requires the backend to have `MUNIN_GEOIP_DB_PATH` configured; rows recorded without a GeoIP DB carry `country = NULL` and roll up into an \"unknown\" bucket. Filter by `subjectType` (e.g. `page`, `cms_entry`) or `source` to scope.", "audiences": [ "admin" @@ -702,8 +702,8 @@ } }, { - "name": "analytics_get_traffic_by_source", - "title": "Analytics: Traffic by UTM source", + "name": "analytics_list_traffic_sources", + "title": "Analytics: List traffic by UTM source", "description": "Views and unique visitors grouped by `utm_source` (with `utm_medium` / `utm_campaign` breakdown). Use this to compare campaign attribution: which channels actually drive engaged traffic vs. just clicks. Rows where `utm_source` is NULL (no campaign params on the URL) roll into a single \"direct/organic\" bucket.", "audiences": [ "admin" @@ -751,7 +751,7 @@ }, { "name": "analytics_list_referrer_hosts", - "title": "Analytics: Top referrer hosts", + "title": "Analytics: List top referrer hosts", "description": "External traffic sources grouped by the host portion of `referrer`. Use this to see which sites are linking to you (HN, Reddit, partner blogs). Same-origin referrers are excluded server-side via the `excludeHost` argument (typically your own production host); pass it to keep internal navigations from drowning out external referrals. Rows with NULL referrer (direct navigation, bookmarks, link-with-`rel=noreferrer`) roll into a single \"direct\" bucket.", "audiences": [ "admin" @@ -803,7 +803,7 @@ }, { "name": "analytics_get_views_over_time", - "title": "Analytics: Daily view time-series", + "title": "Analytics: Get daily view time-series", "description": "Daily view + unique-visitor counts over a recent window. Returns one row per UTC day, ordered oldest → newest, with zero-filled gaps so days with no traffic appear as `views: 0`. Use this to spot trends, weekly patterns, and the impact of campaigns or content launches. In hosts that support MCP Apps this renders an inline time-series chart.", "audiences": [ "admin" @@ -853,7 +853,7 @@ }, { "name": "analytics_get_subject_engagement", - "title": "Analytics: Engagement for one subject", + "title": "Analytics: Get engagement for one subject", "description": "View counts, unique visitors, and average dwell/read-depth for one subject (e.g. one CMS entry) over a recent window. Use this when judging whether a stale entry should be refreshed or archived.", "audiences": [ "admin" @@ -897,7 +897,7 @@ }, { "name": "analytics_get_funnel", - "title": "Analytics: Conversion funnel across ordered steps", + "title": "Analytics: Get conversion funnel across ordered steps", "description": "Compute a conversion funnel over page-view events: how many distinct visitors reached each ordered step, and where they drop off. Pass 2–8 `steps`; each step matches a view event by `subjectType` and/or `subjectId` (e.g. `{ subjectType: \"page\", subjectId: \"/pricing\" }`) and/or a `pathLike` SQL LIKE pattern (e.g. `{ pathLike: \"/blog/%\" }`). Steps are strictly ordered — a visitor counts at step N only if they hit step N after reaching step N-1. Visitors are grouped by a stable actor key (their identified end-user when known, else their anonymous `visitor_id`), so a journey that spans the anonymous → identified transition is not double-counted. Set `stepWindowHours` to require each step to follow the previous within a time budget (e.g. signup within 24h of viewing pricing). Anonymous funnels work without any identity setup. Returns per-step actor counts plus conversion/drop rates.", "audiences": [ "admin" @@ -967,7 +967,7 @@ }, { "name": "analytics_get_contact_journey", - "title": "Analytics: Journey of subjects viewed by a contact", + "title": "Analytics: Get a contact’s view journey", "description": "Chronological list of page-view and search events recorded for one identified visitor. Pass either `contactId` (resolved through `crm_contacts.endUserId`) or `endUserId` directly. Returns the ordered event timeline — what the lead looked at before they reached out, what they searched for, etc. Visitors are linked to an end-user identity by the chat-widget on first chat, or via `window.mn.identify(externalId, userHash)`. Events recorded under a `visitor_id` *before* that link was established are still included retroactively — the link is resolved at read time — so the journey spans the visitor's anonymous history too.", "audiences": [ "admin" @@ -1009,7 +1009,7 @@ }, { "name": "analytics_list_zero_result_searches", - "title": "Analytics: Zero-result search queries", + "title": "Analytics: List zero-result searches", "description": "List recent public search queries that returned zero results. The single best input for \"what should we write about next\" — readers are asking but Munin has no answer.", "audiences": [ "admin" @@ -2232,7 +2232,7 @@ } }, { - "name": "cms_search", + "name": "cms_search_entries", "title": "CMS: Search entries", "description": "Hybrid full-text + semantic search across CMS entries. Each hit carries a match excerpt, the derived title, and summarized field data — long text is shortened to a lead with a word count in `fieldSummary`. Returns drafts and published; the public delivery API runs the same engine but hard-filters to published-only.", "audiences": [ @@ -2919,7 +2919,7 @@ { "name": "conv_list_channels", "title": "Conv: List conversation channels", - "description": "List conversation channels configured for your org. Currently shipping adapters: email and chat (widget). The `voice` and `sms` channel types are reserved for upcoming adapters.", + "description": "List conversation channels of every kind configured for your org — email, chat (widget), SMS and voice.", "audiences": [ "admin" ], @@ -2938,7 +2938,7 @@ { "name": "conv_create_channel", "title": "Conv: Create conversation channel", - "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.", + "description": "Add an `email` or `chat` (widget) conversation channel whose configuration is self-contained. Channel-specific configuration goes in `config`. Voice and SMS channels are vendor-backed and rejected here — they need the credential handoff that conv_configure_voice_sms_channel performs.", "audiences": [ "admin" ], @@ -2955,9 +2955,7 @@ "type": "string", "enum": [ "email", - "voice", - "chat", - "sms" + "chat" ] }, "vendor": { @@ -3405,8 +3403,8 @@ } }, { - "name": "conv_setup_email_channel", - "title": "Conv: Set up email channel", + "name": "conv_configure_email_channel", + "title": "Conv: Configure an email channel", "description": "Create or update an email channel's transport configuration with the non-secret fields only. SMTP / IMAP passwords are rejected here: the channel is created inactive and the response includes a one-time link for a human to enter the passwords in the dashboard — the channel activates once they are saved. Set `outbound.provider: 'mailer'` to send via Munin's configured Resend mailer instead of a custom SMTP host (no password needed, channel is active immediately). Set `defaultAgentMode: 'draft_only'` on an outreach-only inbox so inbound replies are always drafted for human approval rather than auto-sent.", "audiences": [ "admin" @@ -3624,8 +3622,8 @@ } }, { - "name": "conv_send_email_test", - "title": "Conv: Send test email", + "name": "conv_send_email_channel_test", + "title": "Conv: Send a test email", "description": "Send a real test email through this channel's configured outbound transport (SMTP or Mailer). The message is addressed `to` the recipient you pass in. Useful for confirming credentials and deliverability end-to-end.", "audiences": [ "admin" @@ -3658,7 +3656,7 @@ { "name": "conv_request_channel_credentials", "title": "Conv: Request a channel credential link", - "description": "Return a one-time link a human opens to enter an email channel’s secret credentials (SMTP/IMAP passwords) in the dashboard — passwords are never accepted in a conversation. conv_setup_email_channel already returns this link on create; use this tool to mint a fresh link when one expired or to rotate the stored passwords. The link expires after 24 hours.", + "description": "Return a one-time link a human opens to enter a channel’s secret credentials in the dashboard — secrets are never accepted in a conversation. Works for any channel kind: email (SMTP/IMAP passwords) as well as voice and SMS vendor keys. conv_configure_email_channel and conv_configure_voice_sms_channel already return this link on create; use this tool to mint a fresh link when one expired or to rotate the stored secrets. The link expires after 24 hours.", "audiences": [ "admin" ], @@ -3684,7 +3682,7 @@ }, { "name": "conv_request_callback", - "title": "Conv: Place a phone call to the user in this conversation", + "title": "Conv: Request a callback on this conversation", "description": "Place an outbound phone call to the contact in this conversation. Use this only when the user has asked to be called — e.g. \"can you call me?\". The call goes to the phone number already on file for this conversation's contact; you cannot specify an arbitrary number. The org must have an active Vapi voice channel configured. After requesting the call, tell the user briefly that a call is on the way and stop replying — the rest of the conversation happens on the phone.", "audiences": [ "self_service" @@ -3714,7 +3712,7 @@ } }, { - "name": "conv_widget_create_channel", + "name": "conv_create_widget_channel", "title": "Conv: Create chat-widget channel", "description": "Create a chat-widget channel and mint a widget API key (`mn_widget_*`) bound to it. Returns the plaintext key once; store it server-side and pass it as `Authorization: Bearer` when calling POST /v1/widget/messages from the external agent. Scaffolding a frontend from Lovable/Bolt/v0/Replit/Cursor? Read `skill://playbooks/frontend-integration` first — it covers the widget + tracker + CMS wiring end-to-end.", "audiences": [ @@ -3758,7 +3756,7 @@ } }, { - "name": "conv_widget_update_channel", + "name": "conv_update_widget_channel", "title": "Conv: Update chat-widget channel", "description": "Update a chat-widget channel's originAllowlist / webhookOnEscalation. Pass null to clear webhookOnEscalation. The widget API key is unchanged.", "audiences": [ @@ -3805,7 +3803,7 @@ } }, { - "name": "conv_widget_rotate_key", + "name": "conv_rotate_widget_key", "title": "Conv: Rotate widget API key", "description": "Revoke any active widget keys bound to this channel and mint a fresh `mn_widget_*` key. Returns the new plaintext key once. Existing inflight requests using the old key keep working until revocation lands.", "audiences": [ @@ -3831,7 +3829,7 @@ } }, { - "name": "conv_widget_rotate_identity_secret", + "name": "conv_rotate_widget_identity_secret", "title": "Conv: Rotate widget identity-verification secret", "description": "Generate a fresh per-channel HMAC secret used to verify browser-side `data-user-hash` values against `data-external-id`. The previous secret is replaced atomically; any previously-issued user hashes stop verifying immediately and the operator must re-render their pages with newly-computed hashes. Returns the new plaintext once.", "audiences": [ @@ -3857,9 +3855,9 @@ } }, { - "name": "conv_list_channel_vendors", - "title": "Conv: List configurable channel vendors", - "description": "List the voice/SMS channel vendors you can configure, with each vendor’s `kind`, capabilities (call/sendTest), and config fields (name, required, secret, description). Use this to discover what to pass to conv_configure_channel.", + "name": "conv_list_voice_sms_vendors", + "title": "Conv: List voice/SMS channel vendors", + "description": "List the voice/SMS channel vendors you can configure, with each vendor’s `kind`, capabilities (call/sendTest), and config fields (name, required, secret, description). Use this to discover what to pass to conv_configure_voice_sms_channel.", "audiences": [ "admin" ], @@ -3878,7 +3876,7 @@ { "name": "conv_list_channel_options", "title": "Conv: List a channel vendor’s selectable options", - "description": "Discover the selectable options a channel’s vendor offers using the channel’s stored credentials — e.g. Threll workers, Vapi assistants — so you can pass a valid id to conv_configure_channel instead of guessing. The channel must have completed its credential link first. Returns option `groups` (e.g. `workers`, `assistants`), each with `{ value, label, hint }`.", + "description": "Discover the selectable options a channel’s vendor offers using the channel’s stored credentials — e.g. Threll workers, Vapi assistants — so you can pass a valid id to conv_configure_voice_sms_channel instead of guessing. The channel must have completed its credential link first. Returns option `groups` (e.g. `workers`, `assistants`), each with `{ value, label, hint }`.", "audiences": [ "admin" ], @@ -3903,9 +3901,9 @@ } }, { - "name": "conv_configure_channel", + "name": "conv_configure_voice_sms_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. `defaultAgentMode` applies to SMS channels only.", + "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_voice_sms_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" ], @@ -3922,7 +3920,7 @@ "type": "string", "minLength": 1, "maxLength": 40, - "description": "Channel vendor, e.g. \"vapi\", \"threll\", \"twilio\", \"messagebird\". Call conv_list_channel_vendors for the full list and each vendor’s config fields." + "description": "Channel vendor, e.g. \"vapi\", \"threll\", \"twilio\", \"messagebird\". Call conv_list_voice_sms_vendors for the full list and each vendor’s config fields." }, "channelId": { "description": "Pass an existing channel id to update; omit to create a new channel.", @@ -3949,7 +3947,7 @@ "type": "string" }, "additionalProperties": {}, - "description": "Vendor-specific configuration object with the non-secret fields only — conv_list_channel_vendors marks which fields are secret. Secret fields are rejected here; they are entered by a human through the credential link returned on create." + "description": "Vendor-specific configuration object with the non-secret fields only — conv_list_voice_sms_vendors marks which fields are secret. Secret fields are rejected here; they are entered by a human through the credential link returned on create." } }, "required": [ @@ -3960,9 +3958,9 @@ } }, { - "name": "conv_test_channel", - "title": "Conv: Test a channel’s stored credentials", - "description": "Verify a channel’s stored credentials with its vendor (no message sent). The result shape is vendor-specific.", + "name": "conv_test_voice_sms_channel", + "title": "Conv: Test a voice/SMS channel’s stored credentials", + "description": "Verify a voice or SMS channel’s stored credentials with its vendor (no message sent). The result shape is vendor-specific. Email channels are tested with conv_test_email_channel instead.", "audiences": [ "admin" ], @@ -3986,9 +3984,9 @@ } }, { - "name": "conv_send_channel_test", - "title": "Conv: Send a real test message", - "description": "Send a real test message (e.g. SMS) through a channel that supports it, addressed to `to`. Useful for end-to-end deliverability checks.", + "name": "conv_send_voice_sms_channel_test", + "title": "Conv: Send a real test message on a voice/SMS channel", + "description": "Send a real test message through a voice or SMS channel that supports it, addressed to `to`. Useful for end-to-end deliverability checks. Email channels use conv_send_email_channel_test instead.", "audiences": [ "admin" ], @@ -4080,8 +4078,8 @@ } }, { - "name": "crm_find_contact", - "title": "CRM: Find contact by email or phone", + "name": "crm_lookup_contact", + "title": "CRM: Look up contact by email or phone", "description": "Find an existing contact by email and/or phone before creating a new one. Returns null if no match.", "audiences": [ "admin" @@ -4110,7 +4108,7 @@ { "name": "crm_create_contact", "title": "CRM: Create contact", - "description": "Create a new contact. Search with crm_find_contact first to avoid duplicates.", + "description": "Create a new contact. Search with crm_lookup_contact first to avoid duplicates.", "audiences": [ "admin" ], @@ -4625,7 +4623,7 @@ } }, { - "name": "crm_change_stage", + "name": "crm_change_deal_stage", "title": "CRM: Change deal stage", "description": "Move a deal to a new stage. If the destination stage is a won/lost terminal, `closedAt` is stamped automatically.", "audiences": [ @@ -5954,7 +5952,7 @@ }, { "name": "crm_log_my_activity", - "title": "CRM: Log activity as end-user", + "title": "CRM: Log my activity", "description": "Record an activity attributed to the calling end-user agent (e.g. a voice agent logging \"spoke with customer for 4m, follow-up needed\"). Auto-scoped to the end-user's own CRM contact when one exists.", "audiences": [ "self_service" @@ -6443,8 +6441,8 @@ } }, { - "name": "kb_import_website_status", - "title": "KB: Website import status", + "name": "kb_get_website_import_status", + "title": "KB: Get website import status", "description": "Check the progress of a website import started with `kb_import_website`, by the job id it returned. Status is one of `pending` (queued or running), `done` (finished — `summary` reports how many documents were imported), `failed_retryable`/`dead` (will retry / gave up), or `failed`. While `pending`, poll again after a short delay.", "audiences": [ "admin" @@ -7399,7 +7397,7 @@ { "name": "outreach_approve_proposal", "title": "Outreach: Approve proposal", - "description": "Approve one pending outreach proposal, which sends it: an initial proposal creates the outbound conversation and sends the first email (with CTA and unsubscribe footer per campaign settings) via the campaign's channel; a reply or follow-up proposal sends the draft verbatim on its existing conversation. Fails if the proposal is not pending, if the campaign is disabled, if the contact became suppressed since drafting, or — for follow-ups — if the prospect replied after the draft was filed (dismiss it; the reply flow takes over). Returns the proposal with `status: \"sent\"`, `conversationId`, and `sentMessageId`.", + "description": "Approve one pending outreach proposal, which sends it: an initial proposal creates the outbound conversation and delivers the first touch on the campaign's channel — an email (with CTA and unsubscribe footer per campaign settings), an SMS, or an outbound call placed through the channel's voice vendor; a reply or follow-up proposal sends the draft verbatim on its existing conversation. Fails if the proposal is not pending, if the campaign is disabled, if the contact became suppressed since drafting, or — for follow-ups — if the prospect replied after the draft was filed (dismiss it; the reply flow takes over). Returns the proposal with `status: \"sent\"`, `conversationId`, and `sentMessageId`.", "audiences": [ "admin" ], @@ -7555,9 +7553,9 @@ } }, { - "name": "outreach_propose_initial", - "title": "Outreach: Propose initial", - "description": "File one drafted initial outreach email per (campaign, contact) for human approval. Idempotent: re-proposing the same (campaign, contact, kind=initial) throws when a pending draft already exists, or when the contact already has a sent or approved first-touch in this campaign (they were already reached) — call `outreach_list_proposals` first to dedupe. Suppression and consent are re-checked at approve-time too; this tool refuses up-front if the contact is already suppressed.", + "name": "outreach_propose_initial_message", + "title": "Outreach: Propose initial message", + "description": "File one drafted initial outreach message per (campaign, contact) for human approval — an email body, an SMS body, or the script for an outbound call, depending on the channel the campaign sends on. Idempotent: re-proposing the same (campaign, contact, kind=initial) throws when a pending draft already exists, or when the contact already has a sent or approved first-touch in this campaign (they were already reached) — call `outreach_list_proposals` first to dedupe. Suppression and consent are re-checked at approve-time too; this tool refuses up-front if the contact is already suppressed.", "audiences": [ "admin" ], @@ -7686,7 +7684,7 @@ { "name": "outreach_propose_followup", "title": "Outreach: Propose follow-up", - "description": "File a drafted sequence follow-up (step N of the campaign's `sequenceSteps`) on an outreach conversation, for human approval. `step` must be the next step for the conversation, its wait period must have elapsed, and the prospect must not have replied — any inbound reply permanently stops the sequence (the reply flow owns the conversation). One pending follow-up per (campaign, contact); a dismissed follow-up permanently stops the sequence for that contact, so operators who dislike the wording should edit-then-approve instead. Approving sends on the existing conversation with no subject or unsubscribe footer (the thread already carries both).", + "description": "File a drafted sequence follow-up (step N of the campaign's `sequenceSteps`) on an outreach conversation, for human approval. Follow-up sequences run on email campaigns only. `step` must be the next step for the conversation, its wait period must have elapsed, and the prospect must not have replied — any inbound reply permanently stops the sequence (the reply flow owns the conversation). One pending follow-up per (campaign, contact); a dismissed follow-up permanently stops the sequence for that contact, so operators who dislike the wording should edit-then-approve instead. Approving sends on the existing conversation with no subject or unsubscribe footer (the thread already carries both).", "audiences": [ "admin" ], @@ -8054,7 +8052,7 @@ } }, { - "name": "slack_test", + "name": "slack_send_test_message", "title": "Slack: Send test message", "description": "Post a test message to the configured default Slack channel to verify the connection end-to-end. Fails with a specific error when the workspace is not connected, no default route is set, or the bot has not been invited to the channel.", "audiences": [ @@ -8214,7 +8212,7 @@ }, { "name": "connectors_create_connection", - "title": "Connectors: Connect a system", + "title": "Connectors: Create a connection", "description": "Create a connection to a third-party system. `config` takes the vendor’s non-secret fields only — connectors_list_vendors returns the exact fields and marks which are secret. Secret fields are rejected here: the connection is created pending and the response includes a one-time link for a human to enter the secrets in the dashboard. The vendor determines the domain (commerce, bookings). Connection names must be unique within the org.", "audiences": [ "admin" @@ -8501,8 +8499,8 @@ } }, { - "name": "commerce_lookup_orders", - "title": "Commerce: Look up a customer’s orders", + "name": "commerce_list_customer_orders", + "title": "Commerce: List a customer’s orders", "description": "List a customer’s recent store orders by email (newest first), e.g. while handling their support conversation. `connectionId` is only needed when multiple commerce connections are active.", "audiences": [ "admin" @@ -8623,7 +8621,7 @@ }, { "name": "commerce_get_product", - "title": "Commerce: One product with variants and stock", + "title": "Commerce: Get one product", "description": "Fetch one published product from the connected store, including description and per-variant price and availability (`availableForSale`; null when the store doesn’t expose stock). Identify it by `productRef` (from a catalog search) or `sku`. Returns not-found for unpublished products.", "audiences": [ "admin", @@ -8657,8 +8655,8 @@ } }, { - "name": "commerce_get_my_orders", - "title": "Commerce: My recent orders", + "name": "commerce_list_my_orders", + "title": "Commerce: List my recent orders", "description": "List the calling end-user’s recent store orders (newest first). Scoped server-side to the email on the end-user’s own record — other customers’ orders are never visible. `connectionId` is only needed when the org has multiple active store connections.", "audiences": [ "self_service" @@ -8691,7 +8689,7 @@ }, { "name": "commerce_get_my_order", - "title": "Commerce: One of my orders, with tracking", + "title": "Commerce: Get one of my orders", "description": "Fetch one of the calling end-user’s orders, including line items and shipment tracking. Identify it by `orderRef` (from an order listing) or the human-facing `orderNumber` on the order confirmation. Returns not-found unless the order belongs to the calling end-user.", "audiences": [ "self_service" @@ -8724,8 +8722,8 @@ } }, { - "name": "bookings_lookup_bookings", - "title": "Bookings: Look up a guest’s bookings", + "name": "bookings_list_guest_bookings", + "title": "Bookings: List a guest’s bookings", "description": "List a guest’s bookings by email (most recent first), e.g. while handling their support conversation. `connectionId` is only needed when multiple booking connections are active.", "audiences": [ "admin" @@ -8806,7 +8804,7 @@ }, { "name": "bookings_check_availability", - "title": "Bookings: Check table availability", + "title": "Bookings: Check availability", "description": "List open time slots for a date and party size at the connected booking system, so you can offer real times before creating a booking. `connectionId` is only needed when multiple booking connections are active.", "audiences": [ "admin", @@ -8849,7 +8847,7 @@ }, { "name": "bookings_create_booking", - "title": "Bookings: Book a table", + "title": "Bookings: Create a booking", "description": "Create a booking in the connected booking system for a guest email at a date and time, for a given party size. Check bookings_check_availability first. Returns the new bookingRef.", "audiences": [ "admin" @@ -8921,7 +8919,7 @@ }, { "name": "bookings_update_booking", - "title": "Bookings: Modify a booking", + "title": "Bookings: Update a booking", "description": "Change an existing booking (date, time, party size, or note) by `bookingRef`. Only the fields you pass are changed.", "audiences": [ "admin" @@ -9001,8 +8999,8 @@ } }, { - "name": "bookings_get_my_bookings", - "title": "Bookings: My bookings", + "name": "bookings_list_my_bookings", + "title": "Bookings: List my bookings", "description": "List the calling end-user’s restaurant/venue bookings (most recent first). Scoped server-side to the email on the end-user’s own record — other guests’ bookings are never visible. `connectionId` is only needed when the org has multiple active booking connections.", "audiences": [ "self_service" @@ -9035,7 +9033,7 @@ }, { "name": "bookings_get_my_booking", - "title": "Bookings: One of my bookings", + "title": "Bookings: Get one of my bookings", "description": "Fetch one of the calling end-user’s bookings, including party size, start time, and duration. Identify it by `bookingRef` from a listing, or `confirmationCode` where the venue’s system issues one. Returns not-found unless the booking belongs to the calling end-user.", "audiences": [ "self_service" @@ -9069,7 +9067,7 @@ }, { "name": "bookings_create_my_booking", - "title": "Bookings: Book a table for me", + "title": "Bookings: Create my booking", "description": "Create a booking for the calling end-user at a date and time for a party size. The booking is made under the end-user’s own email — you cannot book on behalf of anyone else. Check bookings_check_availability first.", "audiences": [ "self_service" @@ -9135,7 +9133,7 @@ }, { "name": "bookings_update_my_booking", - "title": "Bookings: Modify my booking", + "title": "Bookings: Update my booking", "description": "Change one of the calling end-user’s own bookings (date, time, party size, or note) by `bookingRef`. Returns not-found unless the booking belongs to the calling end-user.", "audiences": [ "self_service" @@ -9215,8 +9213,8 @@ } }, { - "name": "feedback_create", - "title": "Feedback: Create", + "name": "feedback_create_item", + "title": "Feedback: Create a feedback item", "description": "Submit feedback about Munin. Stays local until an org admin approves it; dismissal deletes the item. Set includeOrgName / includeUserName to attach attribution; both default false.", "audiences": [ "admin" @@ -9265,8 +9263,8 @@ } }, { - "name": "feedback_list", - "title": "Feedback: List pending", + "name": "feedback_list_pending_items", + "title": "Feedback: List pending feedback items", "description": "List feedback items in the local outbox awaiting admin action.", "audiences": [ "admin" @@ -9284,8 +9282,8 @@ } }, { - "name": "feedback_get", - "title": "Feedback: Get one", + "name": "feedback_get_item", + "title": "Feedback: Get one feedback item", "description": "Read a single feedback item by id.", "audiences": [ "admin" @@ -9362,9 +9360,9 @@ } }, { - "name": "feedback_search", - "title": "Feedback: Search global roadmap", - "description": "Search the public Munin roadmap for items matching a query. Call this before feedback_create to find an existing item to vote on instead of filing a duplicate. Returns only items the Munin team has published; pending and rejected items are hidden. sort defaults to votes (highest first); use \"recent\" for newest. limit is capped at 100.", + "name": "feedback_search_roadmap", + "title": "Feedback: Search the public roadmap", + "description": "Search the public Munin roadmap for items matching a query. Call this before feedback_create_item to find an existing item to vote on instead of filing a duplicate. Returns only items the Munin team has published; pending and rejected items are hidden. sort defaults to votes (highest first); use \"recent\" for newest. limit is capped at 100.", "audiences": [ "admin" ], @@ -9420,7 +9418,7 @@ } }, { - "name": "feedback_vote", + "name": "feedback_vote_on_roadmap_item", "title": "Feedback: Vote on roadmap item", "description": "Cast this instance's vote on a published roadmap item, optionally attaching a short comment. Idempotent: a second call from the same instance returns { alreadyVoted: true } without inflating the count. Throws feedback_item_not_found if the id is unknown or the item is not public, and feedback_vote_quota_exceeded if the per-instance quota has been hit.", "audiences": [ diff --git a/packages/backend-core/docs-fixtures/skills.json b/packages/backend-core/docs-fixtures/skills.json index db45988b..2397492e 100644 --- a/packages/backend-core/docs-fixtures/skills.json +++ b/packages/backend-core/docs-fixtures/skills.json @@ -24,7 +24,7 @@ "title": "Analytics: Track website traffic", "description": "Mint a public `mn_track_*` API key, drop the one-line tracker script into your site, and query view events to understand what readers engage with.", "mimeType": "text/markdown", - "content": "# Track website traffic\n\nAdd page-view tracking to a landing page, marketing site, docs site, or app shell. The integration is one line of HTML — same ergonomics as the chat widget. Events land in `analytics_view_events` keyed by `subject_type='page'` and a `subject_id` you control (typically the URL path).\n\nUse this when you want to answer questions like:\n- Which pages do readers actually spend time on?\n- Where is traffic coming from (referrer, UTM)?\n- What's the difference between the 100 readers who bounce and the 10 who scroll all the way?\n\nFor tracking individual CMS entries you fetch from Munin's delivery API and render in your own host, see `skill://analytics/track-cms-views` instead — every entry response ships a pre-signed `_tracking` block, no key minting needed, events are keyed by stable entry id. Use *this* skill for traffic-level analytics on the host (homepage, landing pages, anything that isn't a CMS entry) — URL-keyed events from the tracker bundle.\n\n## 1. Mint a tracker key\n\n```jsonc\n{\n \"name\": \"analytics_create_tracker\",\n \"arguments\": {\n \"name\": \"getmunin.com landing\",\n \"allowedOrigins\": [\"https://getmunin.com\"]\n }\n}\n```\n\nResponse includes `trackerKey: \"mn_track_…\"` — shown once. The key is **public** — safe to embed in HTML, mobile clients, anything browsers can see. It can only write view events scoped to your org, never read them.\n\n**`allowedOrigins`** is required — the ingest endpoints reject any request whose `Origin` header doesn't match one of the listed full origins (scheme + host + port, exact match — no wildcards or path prefixes). Multi-environment? List each one (`https://getmunin.com`, `https://dev.getmunin.com`, `http://localhost:3000`).\n\nThe `Origin` header is browser-set and trivially spoofable via curl — origin allowlisting stops casual JS-from-another-site abuse but is not a security boundary on its own. The real defences are key rotation (`analytics_revoke_tracker`) and per-IP rate-limiting at the ingest layer.\n\nEdit later with `analytics_update_tracker({trackerId, allowedOrigins})`. Rotate with `analytics_revoke_tracker` + a fresh `analytics_create_tracker`. List with `analytics_list_trackers`.\n\n## 2. Drop the script tag\n\n```html\n\n```\n\nThat's it. The script auto-fires a page view on `DOMContentLoaded` and writes **one** row to `analytics_view_events` with:\n\n- `subject_type='page'`, `subject_id=`\n- `path=`\n- `referrer=` (initial entry only)\n- `visitor_id=`\n- `utm_source` / `utm_medium` / `utm_campaign` (parsed from `?utm_*` query params)\n- `locale=`\n- `source='tracker'`\n- `dwell_ms` — time the page was *visible*, added when it's hidden or unloaded\n- `read_depth` — the deepest 25/50/75/100 scroll milestone reached, added on the same exit beacon\n- `country` — ISO 3166-1 alpha-2 derived server-side from the client IP via a local MaxMind-format GeoIP DB. Only populated when `MUNIN_GEOIP_DB_PATH` points at a valid `.mmdb` file (e.g. `GeoLite2-Country.mmdb` or DB-IP-Lite); otherwise stays NULL. The IP is consumed only at lookup time and is never persisted.\n\n`Cache-Control: public, max-age=3600` on `tracker.js` so the CDN serves the bundle without hitting your backend per request.\n\n### One row per page view\n\nThe tracker mints a `viewId` (uuid) per page view and sends it on both the initial view and the exit beacon; ingest upserts on `(org_id, client_view_id)`. So dwell time and read depth *enrich the existing row* instead of adding a second one — `views` counts page views, not beacons.\n\nA fresh `viewId` is minted for each SPA route change and each bfcache restore (`pageshow` with `persisted: true`), since both start a new dwell clock. Enrichment is fill-if-null for attribution (`referrer`, `utm_*`, `path`, `locale`, `country`, `metadata`) and max-wins for `dwell_ms` / `read_depth`, so out-of-order beacons — `sendBeacon` guarantees no ordering — can't overwrite the real referrer.\n\nCalls you make yourself (`mn.track`, `mn.trackOnce`, declarative events) carry no `viewId` and always insert their own row.\n\n> **Deploy seam:** before this landed, every page load wrote ≥2 rows (one per beacon). Tracker-sourced `views` therefore drops roughly 50% on the day you upgrade, and `visitors` is unchanged. History is not repairable — old rows carry no `viewId` — so don't compare `views` across the upgrade date.\n\n### What you get without configuring anything\n\n- **Read depth.** Passive `scroll` + `resize` listeners, rAF-throttled, tracking the deepest 25/50/75/100 milestone reached; a page that fits the viewport reports 100. Sent on the exit beacon, so it costs no extra row. Surfaces as `avgReadDepth` in `analytics_get_subject_engagement`.\n- **Exit reporting on two triggers.** Dwell and read depth are sent on `visibilitychange` → hidden *and* on `pagehide`. A `pagehide`-only beacon is the classic reason engagement data is sparse — mobile app-switch and tab-kill often fire only `visibilitychange` — and reporting on both is free here because enrichment is idempotent: same `viewId`, and `dwell_ms` / `read_depth` are max-wins server-side. The usual hidden-then-`pagehide` pair sends once; a reader who returns and leaves again reports a second, larger value. `dwell_ms` accumulates visible time only, so that second report adds the time they actually came back for, not the hours the tab sat in the background.\n- **Route changes.** The script patches `history.pushState` / `replaceState` and listens for `popstate`, closing the previous view (dwell + read depth) and opening a new one per route transition. Changes that don't change `location.pathname` are ignored, so query-param filter and tab state costs nothing — which is why this needs no SPA flag: a classic multi-page site never triggers it.\n- **Canonical subject ids.** See below.\n\n### Optional data attributes\n\n- `data-subject-type=\"docs\"` — override the default `'page'` subject type. Useful when you have multiple surfaces sharing one tracker key.\n- `data-api=\"https://api.your-munin.example\"` — override the API base. Defaults to the origin the script was loaded from.\n\n### Declarative events — no JS file needed\n\nAny element with `data-mn-event` fires a view event when clicked (one delegated capture-phase listener, so it works for elements added later):\n\n```html\nStart free\n```\n\n- `data-mn-event` — the `subject_id`. Required.\n- `data-mn-subject-type` — defaults to `'event'`. Use `'funnel'` for steps you want to feed `analytics_get_funnel`.\n- `data-mn-metadata` — a JSON **object**; anything else is dropped with a console warning rather than sent. This is the zero-JS way to populate `metadata`.\n- `data-mn-once` — fire at most once per browser session (sessionStorage-guarded). For funnel steps that would otherwise re-fire on every navigation.\n\n### Canonical subject ids (automatic)\n\n`subject_id` is `location.pathname`, which on a localized site would report `/en/pricing` and `/nb/pricing` as two different pages, and split the homepage in two the moment `/` redirects to `/en/`. Ingest folds both cases with no configuration:\n\n- **Trailing slashes** are always dropped: `/pricing/` → `/pricing`.\n- **A leading locale segment** is dropped when it matches the locale the page itself reports (``, which every beacon already carries): `/en/pricing` on a page declaring `lang=\"en-US\"` → `/pricing`; `/en/` → `/`. The match is exact against the full tag or its language subtag, so `/enterprise/pricing` and `/uk/pricing` (on `lang=\"en-GB\"`) are left alone.\n\nIds that don't start with `/` — declarative events, funnel steps, entity ids — are never rewritten, and the raw URL is always preserved in `path`, so nothing is lost.\n\nTwo cases the inference can't cover: pages that set no `lang` at all, and a URL prefix that disagrees with the tag — `/no/priser` on pages declaring `lang=\"nb-NO\"` is the classic one. Name those prefixes explicitly:\n\n```jsonc\n{ \"name\": \"analytics_update_tracker\",\n \"arguments\": { \"trackerId\": \"atr_…\", \"canonicalLocales\": [\"no\"] } }\n```\n\nIt applies from the next event with no site redeploy — which is why this lives on the tracker rather than in your markup. Past rows keep the ids they were written with.\n\n## 3. Custom events from JavaScript\n\n`mn.track(subjectId, attrs?)` records anything beyond an auto-fired page view — funnel steps, CTA clicks, modal opens, SPA route changes. Same row schema as a page view (`analytics_view_events`); each call inherits `visitorId`, the script tag's key, and the initial referrer, so attribution stays consistent without you passing it every time.\n\nThe first argument is `subjectId`, the second an optional attribute bag:\n- `subjectType` — defaults to `data-subject-type` on the script tag (typically `'page'`). Override per call if a single tracker handles multiple surfaces (e.g. `'funnel'`, `'cta'`, `'docs'`).\n- `path`, `referrer` — default to the current location and the initial document referrer; pass to override.\n- `dwellMs`, `readDepth`, `metadata` — pass through unchanged.\n- `utm` — falls back to URL `?utm_*` params if not provided.\n\nThe full API on `window.mn`:\n\n| Call | What it does |\n|---|---|\n| `mn.track(subjectId, attrs?)` | One view event. |\n| `mn.trackOnce(subjectId, attrs?)` | Same, but at most once per browser session (sessionStorage-guarded) — the JS twin of `data-mn-once`. |\n| `mn.trackPageView()` | Re-fire the auto page view, minting a fresh `viewId`. |\n| `mn.trackSearch(query, resultCount, opts?)` | A search event (see below). |\n| `mn.trackEntry(token, attrs?)` | A CMS entry view — see `skill://analytics/track-cms-views`. |\n| `mn.getVisitorId()` | The `visitor_id` this browser is sending. |\n| `mn.identify(externalId, userHash)` | Link the visitor to a known user — see `skill://analytics/identify-visitors`. |\n\n## 3b. Search events from any search box\n\n`analytics_list_zero_result_searches` is the best \"what should we write next\" signal you have, but it only sees searches Munin itself ran (the CMS delivery `/search` endpoint). If your site search is Pagefind, Algolia, Typesense, or hand-rolled, report it yourself:\n\n```javascript\nconst hits = await mySearch(query);\nwindow.mn.trackSearch(query, hits.length);\n```\n\nWrites to `analytics_search_events` with `subject_type='site'` (override with `opts.subjectType`, e.g. `'docs'`), the visitor's `visitor_id`, and `locale` from `` unless you pass `opts.locale`. Fire it once per completed search — debounce keystrokes on your side, or the zero-result list fills with prefixes of real queries.\n\nServer-side or non-JS callers can post the same thing directly:\n\n```bash\ncurl -X POST https://api.your-munin.example/v1/a/s \\\n -H \"Content-Type: application/json\" \\\n -d '{\"key\":\"mn_track_…\",\"query\":\"refund policy\",\"resultCount\":0,\"subjectType\":\"docs\"}'\n```\n\nSame tracker key, same origin allowlist, same bot filter and per-IP throttle as `/v1/a/t`.\n\n### Patterns\n\n**Funnel step** — instrument a multi-step flow so you can compute conversion in `analytics_get_subject_engagement` or a custom query. Clicks need no JS at all (`data-mn-event=\"signup-cta-click\" data-mn-subject-type=\"funnel\"`); use the API for steps that aren't clicks:\n\n```javascript\nwindow.mn.trackOnce('checkout-step-2-reached', {\n subjectType: 'funnel',\n metadata: { cartValue: 49 },\n});\n\nwindow.mn.track('checkout-complete', {\n subjectType: 'funnel',\n metadata: { orderId: 'ord_abc', amount: 49 },\n});\n```\n\n`trackOnce` for \"reached this step\" milestones — a plain `track` re-fires every time the component remounts, which inflates the step and flattens the funnel. Use `track` for genuinely repeatable actions like a completed checkout.\n\nThen compute true *ordered* drop-off with `analytics_get_funnel` — it counts distinct visitors who reached each step in sequence, not just raw per-step volumes:\n\n```jsonc\n{ \"name\": \"analytics_get_funnel\",\n \"arguments\": {\n \"steps\": [\n { \"subjectType\": \"funnel\", \"subjectId\": \"signup-cta-click\" },\n { \"subjectType\": \"funnel\", \"subjectId\": \"checkout-step-2-reached\" },\n { \"subjectType\": \"funnel\", \"subjectId\": \"checkout-complete\" }\n ],\n \"sinceDays\": 7,\n \"stepWindowHours\": 24\n } }\n```\n\n`analytics_list_top_subjects({ subjectType: 'funnel' })` still gives the raw per-step counts if you only want volumes.\n\n**SPA route change with dwell** — handled automatically for any router that goes through `history.pushState` / `replaceState` / `popstate`. If yours doesn't, do it manually — pass the same `viewId` to the pair of calls so the second enriches the first instead of adding a row:\n\n```javascript\nlet routeEnter = Date.now();\nlet lastRoute = location.pathname;\nlet viewId = crypto.randomUUID();\nrouter.afterEach((to) => {\n window.mn.track(lastRoute, {\n viewId,\n dwellMs: Date.now() - routeEnter,\n referrer: null,\n });\n routeEnter = Date.now();\n lastRoute = to.path;\n viewId = crypto.randomUUID();\n window.mn.track(to.path, { viewId, referrer: null });\n});\n```\n\n**Scroll milestones** — already handled: the bundle reports the deepest milestone once, on the exit beacon, enriching the page-view row. Don't hand-roll one event per milestone: that's up to four extra rows per page load, and it inflates `views`.\n\nFor a bespoke measure (words read, video watched, a custom \"engaged\" heuristic), pass your own number and it lands in the same column:\n\n```javascript\nwindow.mn.track(location.pathname, { readDepth: myOwnScore(), subjectType: 'page' });\n```\n\n## 4. Query the data\n\nFour admin-only MCP tools cover the questions you'll ask first:\n\n```jsonc\n// Which pages get the most traffic?\n{ \"name\": \"analytics_list_top_subjects\",\n \"arguments\": { \"subjectType\": \"page\", \"source\": \"tracker\", \"sinceDays\": 7, \"limit\": 50 } }\n```\n\nReturns `[{ subjectType, subjectId, views, visitors }]` ordered by view count.\n\n```jsonc\n// How is one specific page performing?\n{ \"name\": \"analytics_get_subject_engagement\",\n \"arguments\": { \"subjectType\": \"page\", \"subjectId\": \"/pricing\", \"sinceDays\": 30 } }\n```\n\nReturns `{ views, visitors, avgDwellMs, avgReadDepth, lastViewAt }` — combine views (volume) with dwell + depth (quality) to separate \"lots of bounces\" from \"fewer but engaged readers.\"\n\n```jsonc\n// What were people searching for that we don't have content for?\n{ \"name\": \"analytics_list_zero_result_searches\",\n \"arguments\": { \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ query, occurrences, lastSeenAt }]`. The single best signal for \"what should we write next\" — readers asked and nothing came back. It covers Munin's own CMS delivery search plus anything you report through `mn.trackSearch` / `POST /v1/a/s`; if your site search never reports, this list is empty no matter how much searching happens.\n\n```jsonc\n// Where are visitors coming from? (requires MUNIN_GEOIP_DB_PATH set; otherwise everything rolls into `country: null`)\n{ \"name\": \"analytics_list_top_countries\",\n \"arguments\": { \"subjectType\": \"page\", \"source\": \"tracker\", \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ country, views, visitors }]`. A row with `country: null` is the unknown bucket — bot IPs filtered upstream don't reach here; this is private/unmappable IPs (loopback, link-local, ranges absent from the mmdb).\n\n```jsonc\n// Which campaigns/channels drive traffic?\n{ \"name\": \"analytics_get_traffic_by_source\",\n \"arguments\": { \"subjectType\": \"page\", \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ utmSource, utmMedium, utmCampaign, views, visitors }]`. The row with all three NULL is the \"direct/organic\" bucket — visits with no UTM params. Compare named-campaign rows against the direct bucket to gauge campaign lift.\n\n```jsonc\n// Which external sites send us traffic?\n{ \"name\": \"analytics_list_referrer_hosts\",\n \"arguments\": { \"excludeHost\": \"getmunin.com\", \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ host, views, visitors }]`. Pass `excludeHost` set to your production host to filter out internal navigations; the `host: null` row is direct/bookmark traffic and `rel=noreferrer` clicks.\n\n```jsonc\n// Daily traffic trend — spot weekly patterns, campaign spikes, content launch lift.\n{ \"name\": \"analytics_get_views_over_time\",\n \"arguments\": { \"subjectType\": \"page\", \"sinceDays\": 30 } }\n```\n\nReturns `[{ day: '2026-05-09', views, visitors }, …]` zero-filled per UTC day, oldest first. Pin to a single page by passing `subjectId`.\n\n```jsonc\n// Where do people drop off in a multi-step flow?\n{ \"name\": \"analytics_get_funnel\",\n \"arguments\": {\n \"steps\": [\n { \"subjectType\": \"page\", \"subjectId\": \"/pricing\" },\n { \"subjectType\": \"page\", \"subjectId\": \"/signup\" },\n { \"pathLike\": \"/onboarding/%\" }\n ],\n \"sinceDays\": 30 } }\n```\n\nReturns per-step `{ index, label, actors, conversionFromPrev, dropFromPrev, conversionFromStart }` plus `overallConversion`. Steps are strictly ordered — a visitor counts at a step only if they reached it *after* the previous one. Each step matches by `subjectType`/`subjectId` and/or a `pathLike` SQL `LIKE` pattern. Visitors are grouped by their identified end-user when known (else the anonymous `visitor_id`), so the anonymous → identified transition isn't double-counted. Add `stepWindowHours` to require each step within a time budget of the previous.\n\nFor anything more bespoke (multi-dimension cohorts, session-windowed paths), the events sit in `analytics_view_events` and `analytics_search_events`; query them directly from a DB client. The MCP tools cover the common questions; the table covers the long tail.\n\n## 5. Server-side / SDK ingestion\n\nFor surfaces that can't run JS (server-rendered emails, mobile native, IoT), call the beacon endpoint directly:\n\n```bash\ncurl -X POST https://api.your-munin.example/v1/a/t \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"key\": \"mn_track_…\",\n \"subjectType\": \"page\",\n \"subjectId\": \"/pricing\",\n \"referrer\": \"https://news.ycombinator.com/\",\n \"visitorId\": \"v-xyz\",\n \"utm\": { \"source\": \"hn\" }\n }'\n```\n\nOr for a 1×1 pixel embedded in HTML emails / image tags:\n\n```html\n\"\"\n```\n\nThe pixel path takes `s` (subjectId, required), `t` (subjectType, defaults to `'page'`), `v` (visitorId, optional). Both routes filter known bot user-agents and rate-limit per IP.\n\nThe beacon also accepts `viewId` — send the same value twice to enrich one row (e.g. an initial call plus a later `dwellMs`) instead of writing two. It is a per-view dedup key, not portable identity: mint a fresh uuid per view and never reuse one across visitors. Omit it and every call inserts its own row, exactly as before.\n\n## 6. Operations\n\n| Task | How |\n|---|---|\n| Rotate a key | `analytics_revoke_tracker({trackerId})` then `analytics_create_tracker({name})`. Old key 401s immediately. |\n| Audit which keys exist | `analytics_list_trackers({})`. Returns id, name, prefix, last-used, revoked-at. |\n| Disable a single page from tracking | Remove the script tag from that page. The script is per-page-load opt-in. |\n| Delete a visitor's data | `DELETE FROM analytics_view_events WHERE visitor_id = $1`. No PII is stored beyond the random uuid — but if a regulator-grade deletion is needed, this is the path. |\n| Fold a locale prefix the page's `lang` doesn't match | `analytics_update_tracker({trackerId, canonicalLocales: [\"no\"]})`. Applies from the next event; no redeploy. Matching prefixes are already folded automatically. |\n| Enable country resolution | Set `MUNIN_GEOIP_DB_PATH=/abs/path/to/GeoLite2-Country.mmdb` (or any MaxMind-format country DB) on the backend before starting. The reader memory-maps the file once at boot; no network calls per request. Disable by unsetting and restarting — the column simply stays NULL for new rows. |\n\n## What NOT to do\n\n- **Don't ship the key as `NEXT_PUBLIC_…` and pretend it's a secret.** It's public by design. Treat it like a Google Analytics measurement id — visible in the page source is normal. The org-scoped write-only authorization is the entire safety story.\n- **Don't reuse the same key across orgs.** Each customer org mints its own. Cross-org leakage isn't possible because the key resolves to one `org_id`.\n- **Don't rely on `dwell_ms` for anything precision-critical.** It's best-effort. Reporting on both `visibilitychange` and `pagehide` catches far more exits than unload alone, but ad-blockers and hard kills still swallow beacons, which leaves the row with `dwell_ms = NULL` (the view itself still counts). It counts only the time the page was visible — a tab backgrounded for an hour contributes nothing — so it approximates attention rather than elapsed time, but a page left open and stared past still counts. Use it for relative ranking, not exact dwell times.\n- **Don't reuse one `viewId` across page views, and don't send it on custom events.** It is an ingest dedup key: a second event carrying an existing `viewId` enriches that row instead of creating its own, so a shared id silently collapses distinct events into one. The bundle handles this for you — this only matters if you post to `/v1/a/t` yourself.\n- **Don't fire one event per scroll milestone.** The bundle reports the deepest milestone once, on exit. Four events per page load is four rows, and `views` becomes meaningless.\n- **Don't put PII in `subject_id` or `metadata`.** Treat them as URL-shaped and tag-shaped respectively. Anything you embed there will sit in an analytics table you'll later query without auth context.\n\n## Related\n\n- `skill://analytics/track-cms-views` — sibling flow for content served by Munin's CMS. Per-entry token-signed pixel + beacon, no key to mint.\n- `skill://cms/review-stale-entries` — periodic curator pass that consults view data to decide whether stale published entries should be refreshed or archived.\n- `skill://conv/setup-chat-widget` — sibling drop-in script (chat widget); identical key-rotation ergonomics.\n" + "content": "# Track website traffic\n\nAdd page-view tracking to a landing page, marketing site, docs site, or app shell. The integration is one line of HTML — same ergonomics as the chat widget. Events land in `analytics_view_events` keyed by `subject_type='page'` and a `subject_id` you control (typically the URL path).\n\nUse this when you want to answer questions like:\n- Which pages do readers actually spend time on?\n- Where is traffic coming from (referrer, UTM)?\n- What's the difference between the 100 readers who bounce and the 10 who scroll all the way?\n\nFor tracking individual CMS entries you fetch from Munin's delivery API and render in your own host, see `skill://analytics/track-cms-views` instead — every entry response ships a pre-signed `_tracking` block, no key minting needed, events are keyed by stable entry id. Use *this* skill for traffic-level analytics on the host (homepage, landing pages, anything that isn't a CMS entry) — URL-keyed events from the tracker bundle.\n\n## 1. Mint a tracker key\n\n```jsonc\n{\n \"name\": \"analytics_create_tracker\",\n \"arguments\": {\n \"name\": \"getmunin.com landing\",\n \"allowedOrigins\": [\"https://getmunin.com\"]\n }\n}\n```\n\nResponse includes `trackerKey: \"mn_track_…\"` — shown once. The key is **public** — safe to embed in HTML, mobile clients, anything browsers can see. It can only write view events scoped to your org, never read them.\n\n**`allowedOrigins`** is required — the ingest endpoints reject any request whose `Origin` header doesn't match one of the listed full origins (scheme + host + port, exact match — no wildcards or path prefixes). Multi-environment? List each one (`https://getmunin.com`, `https://dev.getmunin.com`, `http://localhost:3000`).\n\nThe `Origin` header is browser-set and trivially spoofable via curl — origin allowlisting stops casual JS-from-another-site abuse but is not a security boundary on its own. The real defences are key rotation (`analytics_revoke_tracker`) and per-IP rate-limiting at the ingest layer.\n\nEdit later with `analytics_update_tracker({trackerId, allowedOrigins})`. Rotate with `analytics_revoke_tracker` + a fresh `analytics_create_tracker`. List with `analytics_list_trackers`.\n\n## 2. Drop the script tag\n\n```html\n\n```\n\nThat's it. The script auto-fires a page view on `DOMContentLoaded` and writes **one** row to `analytics_view_events` with:\n\n- `subject_type='page'`, `subject_id=`\n- `path=`\n- `referrer=` (initial entry only)\n- `visitor_id=`\n- `utm_source` / `utm_medium` / `utm_campaign` (parsed from `?utm_*` query params)\n- `locale=`\n- `source='tracker'`\n- `dwell_ms` — time the page was *visible*, added when it's hidden or unloaded\n- `read_depth` — the deepest 25/50/75/100 scroll milestone reached, added on the same exit beacon\n- `country` — ISO 3166-1 alpha-2 derived server-side from the client IP via a local MaxMind-format GeoIP DB. Only populated when `MUNIN_GEOIP_DB_PATH` points at a valid `.mmdb` file (e.g. `GeoLite2-Country.mmdb` or DB-IP-Lite); otherwise stays NULL. The IP is consumed only at lookup time and is never persisted.\n\n`Cache-Control: public, max-age=3600` on `tracker.js` so the CDN serves the bundle without hitting your backend per request.\n\n### One row per page view\n\nThe tracker mints a `viewId` (uuid) per page view and sends it on both the initial view and the exit beacon; ingest upserts on `(org_id, client_view_id)`. So dwell time and read depth *enrich the existing row* instead of adding a second one — `views` counts page views, not beacons.\n\nA fresh `viewId` is minted for each SPA route change and each bfcache restore (`pageshow` with `persisted: true`), since both start a new dwell clock. Enrichment is fill-if-null for attribution (`referrer`, `utm_*`, `path`, `locale`, `country`, `metadata`) and max-wins for `dwell_ms` / `read_depth`, so out-of-order beacons — `sendBeacon` guarantees no ordering — can't overwrite the real referrer.\n\nCalls you make yourself (`mn.track`, `mn.trackOnce`, declarative events) carry no `viewId` and always insert their own row.\n\n> **Deploy seam:** before this landed, every page load wrote ≥2 rows (one per beacon). Tracker-sourced `views` therefore drops roughly 50% on the day you upgrade, and `visitors` is unchanged. History is not repairable — old rows carry no `viewId` — so don't compare `views` across the upgrade date.\n\n### What you get without configuring anything\n\n- **Read depth.** Passive `scroll` + `resize` listeners, rAF-throttled, tracking the deepest 25/50/75/100 milestone reached; a page that fits the viewport reports 100. Sent on the exit beacon, so it costs no extra row. Surfaces as `avgReadDepth` in `analytics_get_subject_engagement`.\n- **Exit reporting on two triggers.** Dwell and read depth are sent on `visibilitychange` → hidden *and* on `pagehide`. A `pagehide`-only beacon is the classic reason engagement data is sparse — mobile app-switch and tab-kill often fire only `visibilitychange` — and reporting on both is free here because enrichment is idempotent: same `viewId`, and `dwell_ms` / `read_depth` are max-wins server-side. The usual hidden-then-`pagehide` pair sends once; a reader who returns and leaves again reports a second, larger value. `dwell_ms` accumulates visible time only, so that second report adds the time they actually came back for, not the hours the tab sat in the background.\n- **Route changes.** The script patches `history.pushState` / `replaceState` and listens for `popstate`, closing the previous view (dwell + read depth) and opening a new one per route transition. Changes that don't change `location.pathname` are ignored, so query-param filter and tab state costs nothing — which is why this needs no SPA flag: a classic multi-page site never triggers it.\n- **Canonical subject ids.** See below.\n\n### Optional data attributes\n\n- `data-subject-type=\"docs\"` — override the default `'page'` subject type. Useful when you have multiple surfaces sharing one tracker key.\n- `data-api=\"https://api.your-munin.example\"` — override the API base. Defaults to the origin the script was loaded from.\n\n### Declarative events — no JS file needed\n\nAny element with `data-mn-event` fires a view event when clicked (one delegated capture-phase listener, so it works for elements added later):\n\n```html\nStart free\n```\n\n- `data-mn-event` — the `subject_id`. Required.\n- `data-mn-subject-type` — defaults to `'event'`. Use `'funnel'` for steps you want to feed `analytics_get_funnel`.\n- `data-mn-metadata` — a JSON **object**; anything else is dropped with a console warning rather than sent. This is the zero-JS way to populate `metadata`.\n- `data-mn-once` — fire at most once per browser session (sessionStorage-guarded). For funnel steps that would otherwise re-fire on every navigation.\n\n### Canonical subject ids (automatic)\n\n`subject_id` is `location.pathname`, which on a localized site would report `/en/pricing` and `/nb/pricing` as two different pages, and split the homepage in two the moment `/` redirects to `/en/`. Ingest folds both cases with no configuration:\n\n- **Trailing slashes** are always dropped: `/pricing/` → `/pricing`.\n- **A leading locale segment** is dropped when it matches the locale the page itself reports (``, which every beacon already carries): `/en/pricing` on a page declaring `lang=\"en-US\"` → `/pricing`; `/en/` → `/`. The match is exact against the full tag or its language subtag, so `/enterprise/pricing` and `/uk/pricing` (on `lang=\"en-GB\"`) are left alone.\n\nIds that don't start with `/` — declarative events, funnel steps, entity ids — are never rewritten, and the raw URL is always preserved in `path`, so nothing is lost.\n\nTwo cases the inference can't cover: pages that set no `lang` at all, and a URL prefix that disagrees with the tag — `/no/priser` on pages declaring `lang=\"nb-NO\"` is the classic one. Name those prefixes explicitly:\n\n```jsonc\n{ \"name\": \"analytics_update_tracker\",\n \"arguments\": { \"trackerId\": \"atr_…\", \"canonicalLocales\": [\"no\"] } }\n```\n\nIt applies from the next event with no site redeploy — which is why this lives on the tracker rather than in your markup. Past rows keep the ids they were written with.\n\n## 3. Custom events from JavaScript\n\n`mn.track(subjectId, attrs?)` records anything beyond an auto-fired page view — funnel steps, CTA clicks, modal opens, SPA route changes. Same row schema as a page view (`analytics_view_events`); each call inherits `visitorId`, the script tag's key, and the initial referrer, so attribution stays consistent without you passing it every time.\n\nThe first argument is `subjectId`, the second an optional attribute bag:\n- `subjectType` — defaults to `data-subject-type` on the script tag (typically `'page'`). Override per call if a single tracker handles multiple surfaces (e.g. `'funnel'`, `'cta'`, `'docs'`).\n- `path`, `referrer` — default to the current location and the initial document referrer; pass to override.\n- `dwellMs`, `readDepth`, `metadata` — pass through unchanged.\n- `utm` — falls back to URL `?utm_*` params if not provided.\n\nThe full API on `window.mn`:\n\n| Call | What it does |\n|---|---|\n| `mn.track(subjectId, attrs?)` | One view event. |\n| `mn.trackOnce(subjectId, attrs?)` | Same, but at most once per browser session (sessionStorage-guarded) — the JS twin of `data-mn-once`. |\n| `mn.trackPageView()` | Re-fire the auto page view, minting a fresh `viewId`. |\n| `mn.trackSearch(query, resultCount, opts?)` | A search event (see below). |\n| `mn.trackEntry(token, attrs?)` | A CMS entry view — see `skill://analytics/track-cms-views`. |\n| `mn.getVisitorId()` | The `visitor_id` this browser is sending. |\n| `mn.identify(externalId, userHash)` | Link the visitor to a known user — see `skill://analytics/identify-visitors`. |\n\n## 3b. Search events from any search box\n\n`analytics_list_zero_result_searches` is the best \"what should we write next\" signal you have, but it only sees searches Munin itself ran (the CMS delivery `/search` endpoint). If your site search is Pagefind, Algolia, Typesense, or hand-rolled, report it yourself:\n\n```javascript\nconst hits = await mySearch(query);\nwindow.mn.trackSearch(query, hits.length);\n```\n\nWrites to `analytics_search_events` with `subject_type='site'` (override with `opts.subjectType`, e.g. `'docs'`), the visitor's `visitor_id`, and `locale` from `` unless you pass `opts.locale`. Fire it once per completed search — debounce keystrokes on your side, or the zero-result list fills with prefixes of real queries.\n\nServer-side or non-JS callers can post the same thing directly:\n\n```bash\ncurl -X POST https://api.your-munin.example/v1/a/s \\\n -H \"Content-Type: application/json\" \\\n -d '{\"key\":\"mn_track_…\",\"query\":\"refund policy\",\"resultCount\":0,\"subjectType\":\"docs\"}'\n```\n\nSame tracker key, same origin allowlist, same bot filter and per-IP throttle as `/v1/a/t`.\n\n### Patterns\n\n**Funnel step** — instrument a multi-step flow so you can compute conversion in `analytics_get_subject_engagement` or a custom query. Clicks need no JS at all (`data-mn-event=\"signup-cta-click\" data-mn-subject-type=\"funnel\"`); use the API for steps that aren't clicks:\n\n```javascript\nwindow.mn.trackOnce('checkout-step-2-reached', {\n subjectType: 'funnel',\n metadata: { cartValue: 49 },\n});\n\nwindow.mn.track('checkout-complete', {\n subjectType: 'funnel',\n metadata: { orderId: 'ord_abc', amount: 49 },\n});\n```\n\n`trackOnce` for \"reached this step\" milestones — a plain `track` re-fires every time the component remounts, which inflates the step and flattens the funnel. Use `track` for genuinely repeatable actions like a completed checkout.\n\nThen compute true *ordered* drop-off with `analytics_get_funnel` — it counts distinct visitors who reached each step in sequence, not just raw per-step volumes:\n\n```jsonc\n{ \"name\": \"analytics_get_funnel\",\n \"arguments\": {\n \"steps\": [\n { \"subjectType\": \"funnel\", \"subjectId\": \"signup-cta-click\" },\n { \"subjectType\": \"funnel\", \"subjectId\": \"checkout-step-2-reached\" },\n { \"subjectType\": \"funnel\", \"subjectId\": \"checkout-complete\" }\n ],\n \"sinceDays\": 7,\n \"stepWindowHours\": 24\n } }\n```\n\n`analytics_list_top_subjects({ subjectType: 'funnel' })` still gives the raw per-step counts if you only want volumes.\n\n**SPA route change with dwell** — handled automatically for any router that goes through `history.pushState` / `replaceState` / `popstate`. If yours doesn't, do it manually — pass the same `viewId` to the pair of calls so the second enriches the first instead of adding a row:\n\n```javascript\nlet routeEnter = Date.now();\nlet lastRoute = location.pathname;\nlet viewId = crypto.randomUUID();\nrouter.afterEach((to) => {\n window.mn.track(lastRoute, {\n viewId,\n dwellMs: Date.now() - routeEnter,\n referrer: null,\n });\n routeEnter = Date.now();\n lastRoute = to.path;\n viewId = crypto.randomUUID();\n window.mn.track(to.path, { viewId, referrer: null });\n});\n```\n\n**Scroll milestones** — already handled: the bundle reports the deepest milestone once, on the exit beacon, enriching the page-view row. Don't hand-roll one event per milestone: that's up to four extra rows per page load, and it inflates `views`.\n\nFor a bespoke measure (words read, video watched, a custom \"engaged\" heuristic), pass your own number and it lands in the same column:\n\n```javascript\nwindow.mn.track(location.pathname, { readDepth: myOwnScore(), subjectType: 'page' });\n```\n\n## 4. Query the data\n\nFour admin-only MCP tools cover the questions you'll ask first:\n\n```jsonc\n// Which pages get the most traffic?\n{ \"name\": \"analytics_list_top_subjects\",\n \"arguments\": { \"subjectType\": \"page\", \"source\": \"tracker\", \"sinceDays\": 7, \"limit\": 50 } }\n```\n\nReturns `[{ subjectType, subjectId, views, visitors }]` ordered by view count.\n\n```jsonc\n// How is one specific page performing?\n{ \"name\": \"analytics_get_subject_engagement\",\n \"arguments\": { \"subjectType\": \"page\", \"subjectId\": \"/pricing\", \"sinceDays\": 30 } }\n```\n\nReturns `{ views, visitors, avgDwellMs, avgReadDepth, lastViewAt }` — combine views (volume) with dwell + depth (quality) to separate \"lots of bounces\" from \"fewer but engaged readers.\"\n\n```jsonc\n// What were people searching for that we don't have content for?\n{ \"name\": \"analytics_list_zero_result_searches\",\n \"arguments\": { \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ query, occurrences, lastSeenAt }]`. The single best signal for \"what should we write next\" — readers asked and nothing came back. It covers Munin's own CMS delivery search plus anything you report through `mn.trackSearch` / `POST /v1/a/s`; if your site search never reports, this list is empty no matter how much searching happens.\n\n```jsonc\n// Where are visitors coming from? (requires MUNIN_GEOIP_DB_PATH set; otherwise everything rolls into `country: null`)\n{ \"name\": \"analytics_list_top_countries\",\n \"arguments\": { \"subjectType\": \"page\", \"source\": \"tracker\", \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ country, views, visitors }]`. A row with `country: null` is the unknown bucket — bot IPs filtered upstream don't reach here; this is private/unmappable IPs (loopback, link-local, ranges absent from the mmdb).\n\n```jsonc\n// Which campaigns/channels drive traffic?\n{ \"name\": \"analytics_list_traffic_sources\",\n \"arguments\": { \"subjectType\": \"page\", \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ utmSource, utmMedium, utmCampaign, views, visitors }]`. The row with all three NULL is the \"direct/organic\" bucket — visits with no UTM params. Compare named-campaign rows against the direct bucket to gauge campaign lift.\n\n```jsonc\n// Which external sites send us traffic?\n{ \"name\": \"analytics_list_referrer_hosts\",\n \"arguments\": { \"excludeHost\": \"getmunin.com\", \"sinceDays\": 30, \"limit\": 50 } }\n```\n\nReturns `[{ host, views, visitors }]`. Pass `excludeHost` set to your production host to filter out internal navigations; the `host: null` row is direct/bookmark traffic and `rel=noreferrer` clicks.\n\n```jsonc\n// Daily traffic trend — spot weekly patterns, campaign spikes, content launch lift.\n{ \"name\": \"analytics_get_views_over_time\",\n \"arguments\": { \"subjectType\": \"page\", \"sinceDays\": 30 } }\n```\n\nReturns `[{ day: '2026-05-09', views, visitors }, …]` zero-filled per UTC day, oldest first. Pin to a single page by passing `subjectId`.\n\n```jsonc\n// Where do people drop off in a multi-step flow?\n{ \"name\": \"analytics_get_funnel\",\n \"arguments\": {\n \"steps\": [\n { \"subjectType\": \"page\", \"subjectId\": \"/pricing\" },\n { \"subjectType\": \"page\", \"subjectId\": \"/signup\" },\n { \"pathLike\": \"/onboarding/%\" }\n ],\n \"sinceDays\": 30 } }\n```\n\nReturns per-step `{ index, label, actors, conversionFromPrev, dropFromPrev, conversionFromStart }` plus `overallConversion`. Steps are strictly ordered — a visitor counts at a step only if they reached it *after* the previous one. Each step matches by `subjectType`/`subjectId` and/or a `pathLike` SQL `LIKE` pattern. Visitors are grouped by their identified end-user when known (else the anonymous `visitor_id`), so the anonymous → identified transition isn't double-counted. Add `stepWindowHours` to require each step within a time budget of the previous.\n\nFor anything more bespoke (multi-dimension cohorts, session-windowed paths), the events sit in `analytics_view_events` and `analytics_search_events`; query them directly from a DB client. The MCP tools cover the common questions; the table covers the long tail.\n\n## 5. Server-side / SDK ingestion\n\nFor surfaces that can't run JS (server-rendered emails, mobile native, IoT), call the beacon endpoint directly:\n\n```bash\ncurl -X POST https://api.your-munin.example/v1/a/t \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"key\": \"mn_track_…\",\n \"subjectType\": \"page\",\n \"subjectId\": \"/pricing\",\n \"referrer\": \"https://news.ycombinator.com/\",\n \"visitorId\": \"v-xyz\",\n \"utm\": { \"source\": \"hn\" }\n }'\n```\n\nOr for a 1×1 pixel embedded in HTML emails / image tags:\n\n```html\n\"\"\n```\n\nThe pixel path takes `s` (subjectId, required), `t` (subjectType, defaults to `'page'`), `v` (visitorId, optional). Both routes filter known bot user-agents and rate-limit per IP.\n\nThe beacon also accepts `viewId` — send the same value twice to enrich one row (e.g. an initial call plus a later `dwellMs`) instead of writing two. It is a per-view dedup key, not portable identity: mint a fresh uuid per view and never reuse one across visitors. Omit it and every call inserts its own row, exactly as before.\n\n## 6. Operations\n\n| Task | How |\n|---|---|\n| Rotate a key | `analytics_revoke_tracker({trackerId})` then `analytics_create_tracker({name})`. Old key 401s immediately. |\n| Audit which keys exist | `analytics_list_trackers({})`. Returns id, name, prefix, last-used, revoked-at. |\n| Disable a single page from tracking | Remove the script tag from that page. The script is per-page-load opt-in. |\n| Delete a visitor's data | `DELETE FROM analytics_view_events WHERE visitor_id = $1`. No PII is stored beyond the random uuid — but if a regulator-grade deletion is needed, this is the path. |\n| Fold a locale prefix the page's `lang` doesn't match | `analytics_update_tracker({trackerId, canonicalLocales: [\"no\"]})`. Applies from the next event; no redeploy. Matching prefixes are already folded automatically. |\n| Enable country resolution | Set `MUNIN_GEOIP_DB_PATH=/abs/path/to/GeoLite2-Country.mmdb` (or any MaxMind-format country DB) on the backend before starting. The reader memory-maps the file once at boot; no network calls per request. Disable by unsetting and restarting — the column simply stays NULL for new rows. |\n\n## What NOT to do\n\n- **Don't ship the key as `NEXT_PUBLIC_…` and pretend it's a secret.** It's public by design. Treat it like a Google Analytics measurement id — visible in the page source is normal. The org-scoped write-only authorization is the entire safety story.\n- **Don't reuse the same key across orgs.** Each customer org mints its own. Cross-org leakage isn't possible because the key resolves to one `org_id`.\n- **Don't rely on `dwell_ms` for anything precision-critical.** It's best-effort. Reporting on both `visibilitychange` and `pagehide` catches far more exits than unload alone, but ad-blockers and hard kills still swallow beacons, which leaves the row with `dwell_ms = NULL` (the view itself still counts). It counts only the time the page was visible — a tab backgrounded for an hour contributes nothing — so it approximates attention rather than elapsed time, but a page left open and stared past still counts. Use it for relative ranking, not exact dwell times.\n- **Don't reuse one `viewId` across page views, and don't send it on custom events.** It is an ingest dedup key: a second event carrying an existing `viewId` enriches that row instead of creating its own, so a shared id silently collapses distinct events into one. The bundle handles this for you — this only matters if you post to `/v1/a/t` yourself.\n- **Don't fire one event per scroll milestone.** The bundle reports the deepest milestone once, on exit. Four events per page load is four rows, and `views` becomes meaningless.\n- **Don't put PII in `subject_id` or `metadata`.** Treat them as URL-shaped and tag-shaped respectively. Anything you embed there will sit in an analytics table you'll later query without auth context.\n\n## Related\n\n- `skill://analytics/track-cms-views` — sibling flow for content served by Munin's CMS. Per-entry token-signed pixel + beacon, no key to mint.\n- `skill://cms/review-stale-entries` — periodic curator pass that consults view data to decide whether stale published entries should be refreshed or archived.\n- `skill://conv/setup-chat-widget` — sibling drop-in script (chat widget); identical key-rotation ergonomics.\n" }, { "uri": "skill://bookings/manage-bookings", @@ -33,7 +33,7 @@ "title": "Bookings: Book and manage tables", "description": "Check a guest's bookings, check availability, and create, modify, or cancel a table booking in the org's connected booking system.", "mimeType": "text/markdown", - "content": "# Book and manage tables\n\nBookings run against the org's connected booking system (e.g. Gastroplanner). You can look up, create, modify, and cancel bookings. `startsAt` is venue-local time (the system does not expose a timezone) — present it as-is, don't convert it. `connectionId` is only needed when the org has more than one active booking connection.\n\n## Before booking: check availability\n\nAlways call `bookings_check_availability` with the `date` (YYYY-MM-DD) and `partySize` before creating a booking — offer the guest real open slots rather than guessing a time. It returns the open `time` slots (and their seating duration). Creating a booking for an unavailable slot fails with a no-availability error.\n\n## Self-service (the guest's own agent)\n\nEvery self-service call is fixed to the calling end-user's own identity — you can never act on another guest's booking or book under a different email.\n\n- `bookings_get_my_bookings` / `bookings_get_my_booking` — the guest's own bookings.\n- `bookings_create_my_booking` — book a table for the guest. The booking is made under their own email automatically. Pass `date`, `time` (HH:MM), `partySize`; optionally `name`, `phone`, `note`.\n- `bookings_update_my_booking` — change the guest's own booking (`date`, `time`, `partySize`, or `note`) by `bookingRef`.\n- `bookings_cancel_my_booking` — cancel the guest's own booking by `bookingRef`.\n\nIf the session has no email identity, these return an error — tell the guest you can't manage bookings in this session and offer a human handover (`conv_request_human`).\n\n## Admin (support agent working a conversation)\n\n- `bookings_lookup_bookings` / `bookings_lookup_booking` — look up by a guest's `email` (use the verified email of the contact whose conversation you're handling).\n- `bookings_create_booking` — book a table for a guest `email`.\n- `bookings_update_booking` / `bookings_cancel_booking` — modify or cancel by `bookingRef`.\n\n## Care with writes\n\nCreating, modifying, and cancelling change the restaurant's live reservation system and (cancel especially) cannot be undone. Confirm the specifics with the guest — date, time, party size, which booking — before you call a write tool, and read back the result.\n" + "content": "# Book and manage tables\n\nBookings run against the org's connected booking system (e.g. Gastroplanner). You can look up, create, modify, and cancel bookings. `startsAt` is venue-local time (the system does not expose a timezone) — present it as-is, don't convert it. `connectionId` is only needed when the org has more than one active booking connection.\n\n## Before booking: check availability\n\nAlways call `bookings_check_availability` with the `date` (YYYY-MM-DD) and `partySize` before creating a booking — offer the guest real open slots rather than guessing a time. It returns the open `time` slots (and their seating duration). Creating a booking for an unavailable slot fails with a no-availability error.\n\n## Self-service (the guest's own agent)\n\nEvery self-service call is fixed to the calling end-user's own identity — you can never act on another guest's booking or book under a different email.\n\n- `bookings_list_my_bookings` / `bookings_get_my_booking` — the guest's own bookings.\n- `bookings_create_my_booking` — book a table for the guest. The booking is made under their own email automatically. Pass `date`, `time` (HH:MM), `partySize`; optionally `name`, `phone`, `note`.\n- `bookings_update_my_booking` — change the guest's own booking (`date`, `time`, `partySize`, or `note`) by `bookingRef`.\n- `bookings_cancel_my_booking` — cancel the guest's own booking by `bookingRef`.\n\nIf the session has no email identity, these return an error — tell the guest you can't manage bookings in this session and offer a human handover (`conv_request_human`).\n\n## Admin (support agent working a conversation)\n\n- `bookings_list_guest_bookings` / `bookings_lookup_booking` — look up by a guest's `email` (use the verified email of the contact whose conversation you're handling).\n- `bookings_create_booking` — book a table for a guest `email`.\n- `bookings_update_booking` / `bookings_cancel_booking` — modify or cancel by `bookingRef`.\n\n## Care with writes\n\nCreating, modifying, and cancelling change the restaurant's live reservation system and (cancel especially) cannot be undone. Confirm the specifics with the guest — date, time, party size, which booking — before you call a write tool, and read back the result.\n" }, { "uri": "skill://cms/author-with-blocks", @@ -42,7 +42,7 @@ "title": "CMS: Author content with blocks", "description": "Define typed block types on a collection and author rich in-article content (callouts, galleries, product cards) whose embedded assets and entry references are validated, expanded on read, and tracked for deletion safety.", "mimeType": "text/markdown", - "content": "# Author content with blocks\n\nA `blocks` field holds an ordered list of typed components — the way to put richer structure (callouts, galleries, embeds, product cards) inside an article instead of a single prose body. Each block type is a named set of fields, so everything you can do with a top-level field (assets, references, inline `asset://` images, validation) works inside a block too.\n\nUse `blocks` for renderable structured content. Use `json` only for opaque, non-renderable data (config, metadata) — `json` is never expanded, never indexed, and not covered by the asset delete guard, and the server rejects `asset://` tokens or block-shaped arrays inside it.\n\n## 1 — Declare block types on the collection\n\nA `blocks` field carries its allowed block types in `options.blockTypes`. Each block type has a `name` (lowercase, the same rules as field names), an optional `label`, an optional `description`, and a list of `fields` (any field types except `blocks` — blocks cannot nest). Use `description` to say what the block is for and when to reach for it — e.g. \"Highlights a warning the reader must not miss; don't use for ordinary body text\" — so the agent picks the right block while authoring.\n\n```jsonc\n{\n \"name\": \"cms_create_collection\",\n \"arguments\": {\n \"name\": \"Articles\",\n \"slug\": \"articles\",\n \"fields\": [\n { \"name\": \"title\", \"type\": \"text\", \"required\": true },\n { \"name\": \"slug\", \"type\": \"text\", \"required\": true },\n {\n \"name\": \"body\",\n \"type\": \"blocks\",\n \"options\": {\n \"blockTypes\": [\n { \"name\": \"callout\", \"label\": \"Callout\", \"description\": \"A highlighted box for an important aside or warning the reader must not miss. Not for ordinary body text.\", \"fields\": [\n { \"name\": \"tone\", \"type\": \"select\", \"options\": { \"choices\": [\"info\", \"warn\"] } },\n { \"name\": \"text\", \"type\": \"markdown\" }\n ]},\n { \"name\": \"gallery\", \"label\": \"Gallery\", \"description\": \"A set of images shown together as a gallery. Use when you have two or more related photos; for a single image embed it inline instead.\", \"fields\": [\n { \"name\": \"images\", \"type\": \"array\", \"options\": { \"items\": { \"name\": \"img\", \"type\": \"asset\" } } }\n ]},\n { \"name\": \"product_card\", \"label\": \"Product card\", \"description\": \"Promotes one product from the products collection inline in the article.\", \"fields\": [\n { \"name\": \"product\", \"type\": \"reference\", \"options\": { \"targetCollection\": \"products\" } }\n ]}\n ]\n }\n }\n ]\n }\n}\n```\n\n## 2 — Author block content on an entry\n\nA block instance is `{ \"type\", \"key\", \"props\" }`: `type` names one of the collection's block types, `key` is an opaque stable id for your own list rendering (optional — defaulted if omitted), and `props` holds the values keyed by that block type's field names.\n\n```jsonc\n{\n \"name\": \"cms_create_entry\",\n \"arguments\": {\n \"collection\": \"articles\",\n \"slug\": \"spring-launch\",\n \"data\": {\n \"title\": \"Spring launch\",\n \"slug\": \"spring-launch\",\n \"body\": [\n { \"type\": \"callout\", \"key\": \"c1\", \"props\": { \"tone\": \"info\", \"text\": \"Now shipping! ![hero](asset://)\" } },\n { \"type\": \"gallery\", \"key\": \"g1\", \"props\": { \"images\": [\"\", \"\"] } },\n { \"type\": \"product_card\", \"key\": \"p1\", \"props\": { \"product\": \"\" } }\n ]\n },\n \"status\": \"draft\"\n }\n}\n```\n\n`props` are validated against the block type's fields. An unknown block `type`, a missing/non-object `props`, or a bad prop value fails with `cms_invalid` (a clean 400) — fix the block and retry. Inline `asset://` tokens inside a block's `markdown`/`rich_text` prop must reference an already-uploaded asset (see `skill://cms/upload-asset-and-embed`); an unknown inline asset is rejected on write.\n\n## 3 — Reading blocks back\n\nOn the delivery API and `cms_get_entry`:\n\n- Typed `asset` props (and `array`) expand to the full asset object (`publicUrl`, `altText`, `mime`, `sizeBytes`).\n- Inline `asset://` tokens in block prose are rewritten to the asset's `publicUrl`, and an `_assets` map (keyed by asset id) is attached alongside `data` for `altText`/dimensions.\n- `reference` props stay raw ids by default. Request expansion explicitly: delivery `GET /v1/cms//articles/?include=references`, or `cms_get_entry` / `cms_search` with `\"include\": [\"references\"]` (`cms_list_entries` returns summaries and never expands). Expanded references resolve **one level** to `{ id, slug, collection, locale, data }`; references inside the referenced entry are not followed.\n\n## Linking to another entry inline (`ref://`)\n\nTo link or embed another entry from within prose (a `markdown`/`rich_text` field or block prop), write a `ref://` token — e.g. `see [our pricing](ref://ent_pricing)`. Unlike `asset://` (which is rewritten to a URL on read), a `ref://` token is **left in place**, because the server doesn't know your site's routing. Instead, under `?include=references` the response carries a `_refs` map keyed by entry id → `{ id, slug, collection, locale, data }`; your renderer detects `ref://`, looks it up in `_refs`, and builds its own link (`/blog/`) or embed. Tokens whose target isn't published (or doesn't exist) simply have no `_refs` entry.\n\n## What NOT to do\n\n- **Don't nest blocks.** A block type's fields cannot include another `blocks` field.\n- **Don't put renderable content in `json`.** Assets, references, and `asset://` tokens in `json` are invisible to expansion and the delete guard; the server rejects the obvious cases. Use `blocks` (or a typed `asset`/`reference` field) instead.\n- **Don't reference an asset before it is uploaded.** Inline tokens in block prose are validated on write.\n\n## Related\n\n- `skill://cms/upload-asset-and-embed` — upload an asset and get the id you embed in a block.\n- `skill://cms/publish-entry` — the update + publish dance once the block content is ready.\n- `skill://cms/design-collection` — choosing field types when you design the collection.\n" + "content": "# Author content with blocks\n\nA `blocks` field holds an ordered list of typed components — the way to put richer structure (callouts, galleries, embeds, product cards) inside an article instead of a single prose body. Each block type is a named set of fields, so everything you can do with a top-level field (assets, references, inline `asset://` images, validation) works inside a block too.\n\nUse `blocks` for renderable structured content. Use `json` only for opaque, non-renderable data (config, metadata) — `json` is never expanded, never indexed, and not covered by the asset delete guard, and the server rejects `asset://` tokens or block-shaped arrays inside it.\n\n## 1 — Declare block types on the collection\n\nA `blocks` field carries its allowed block types in `options.blockTypes`. Each block type has a `name` (lowercase, the same rules as field names), an optional `label`, an optional `description`, and a list of `fields` (any field types except `blocks` — blocks cannot nest). Use `description` to say what the block is for and when to reach for it — e.g. \"Highlights a warning the reader must not miss; don't use for ordinary body text\" — so the agent picks the right block while authoring.\n\n```jsonc\n{\n \"name\": \"cms_create_collection\",\n \"arguments\": {\n \"name\": \"Articles\",\n \"slug\": \"articles\",\n \"fields\": [\n { \"name\": \"title\", \"type\": \"text\", \"required\": true },\n { \"name\": \"slug\", \"type\": \"text\", \"required\": true },\n {\n \"name\": \"body\",\n \"type\": \"blocks\",\n \"options\": {\n \"blockTypes\": [\n { \"name\": \"callout\", \"label\": \"Callout\", \"description\": \"A highlighted box for an important aside or warning the reader must not miss. Not for ordinary body text.\", \"fields\": [\n { \"name\": \"tone\", \"type\": \"select\", \"options\": { \"choices\": [\"info\", \"warn\"] } },\n { \"name\": \"text\", \"type\": \"markdown\" }\n ]},\n { \"name\": \"gallery\", \"label\": \"Gallery\", \"description\": \"A set of images shown together as a gallery. Use when you have two or more related photos; for a single image embed it inline instead.\", \"fields\": [\n { \"name\": \"images\", \"type\": \"array\", \"options\": { \"items\": { \"name\": \"img\", \"type\": \"asset\" } } }\n ]},\n { \"name\": \"product_card\", \"label\": \"Product card\", \"description\": \"Promotes one product from the products collection inline in the article.\", \"fields\": [\n { \"name\": \"product\", \"type\": \"reference\", \"options\": { \"targetCollection\": \"products\" } }\n ]}\n ]\n }\n }\n ]\n }\n}\n```\n\n## 2 — Author block content on an entry\n\nA block instance is `{ \"type\", \"key\", \"props\" }`: `type` names one of the collection's block types, `key` is an opaque stable id for your own list rendering (optional — defaulted if omitted), and `props` holds the values keyed by that block type's field names.\n\n```jsonc\n{\n \"name\": \"cms_create_entry\",\n \"arguments\": {\n \"collection\": \"articles\",\n \"slug\": \"spring-launch\",\n \"data\": {\n \"title\": \"Spring launch\",\n \"slug\": \"spring-launch\",\n \"body\": [\n { \"type\": \"callout\", \"key\": \"c1\", \"props\": { \"tone\": \"info\", \"text\": \"Now shipping! ![hero](asset://)\" } },\n { \"type\": \"gallery\", \"key\": \"g1\", \"props\": { \"images\": [\"\", \"\"] } },\n { \"type\": \"product_card\", \"key\": \"p1\", \"props\": { \"product\": \"\" } }\n ]\n },\n \"status\": \"draft\"\n }\n}\n```\n\n`props` are validated against the block type's fields. An unknown block `type`, a missing/non-object `props`, or a bad prop value fails with `cms_invalid` (a clean 400) — fix the block and retry. Inline `asset://` tokens inside a block's `markdown`/`rich_text` prop must reference an already-uploaded asset (see `skill://cms/upload-asset-and-embed`); an unknown inline asset is rejected on write.\n\n## 3 — Reading blocks back\n\nOn the delivery API and `cms_get_entry`:\n\n- Typed `asset` props (and `array`) expand to the full asset object (`publicUrl`, `altText`, `mime`, `sizeBytes`).\n- Inline `asset://` tokens in block prose are rewritten to the asset's `publicUrl`, and an `_assets` map (keyed by asset id) is attached alongside `data` for `altText`/dimensions.\n- `reference` props stay raw ids by default. Request expansion explicitly: delivery `GET /v1/cms//articles/?include=references`, or `cms_get_entry` / `cms_search_entries` with `\"include\": [\"references\"]` (`cms_list_entries` returns summaries and never expands). Expanded references resolve **one level** to `{ id, slug, collection, locale, data }`; references inside the referenced entry are not followed.\n\n## Linking to another entry inline (`ref://`)\n\nTo link or embed another entry from within prose (a `markdown`/`rich_text` field or block prop), write a `ref://` token — e.g. `see [our pricing](ref://ent_pricing)`. Unlike `asset://` (which is rewritten to a URL on read), a `ref://` token is **left in place**, because the server doesn't know your site's routing. Instead, under `?include=references` the response carries a `_refs` map keyed by entry id → `{ id, slug, collection, locale, data }`; your renderer detects `ref://`, looks it up in `_refs`, and builds its own link (`/blog/`) or embed. Tokens whose target isn't published (or doesn't exist) simply have no `_refs` entry.\n\n## What NOT to do\n\n- **Don't nest blocks.** A block type's fields cannot include another `blocks` field.\n- **Don't put renderable content in `json`.** Assets, references, and `asset://` tokens in `json` are invisible to expansion and the delete guard; the server rejects the obvious cases. Use `blocks` (or a typed `asset`/`reference` field) instead.\n- **Don't reference an asset before it is uploaded.** Inline tokens in block prose are validated on write.\n\n## Related\n\n- `skill://cms/upload-asset-and-embed` — upload an asset and get the id you embed in a block.\n- `skill://cms/publish-entry` — the update + publish dance once the block content is ready.\n- `skill://cms/design-collection` — choosing field types when you design the collection.\n" }, { "uri": "skill://cms/design-collection", @@ -69,7 +69,7 @@ "title": "CMS: Migrate content in bulk", "description": "Move entries from an external CMS (or between Munin collections) idempotently — preserve references, rewrite assets, reconcile schema drift.", "mimeType": "text/markdown", - "content": "# Migrate content in bulk\nImporting a customer's existing content (Webflow, Contentful, headless WordPress, an old Munin collection) into a Munin CMS collection. Designed to be **idempotent** so a partial run can resume.\n\n## TL;DR\n\n1. Inspect or design the destination collection schema (`cms_get_collection` / `cms_create_collection`).\n2. Pick a stable external id and store it in a custom field so re-runs find existing entries via `cms_search`.\n3. For each source row: search for an existing entry → create new or update existing.\n4. Rewire references after all entries are imported (two-pass).\n5. Verify count, spot-check a few entries.\n\n## Step 1 — destination schema\n\nIf migrating into a new collection:\n\n```jsonc\n{ \"name\": \"cms_list_collections\", \"arguments\": {} }\n```\n\n```jsonc\n{\n \"name\": \"cms_create_collection\",\n \"arguments\": {\n \"name\": \"Blog\",\n \"slug\": \"blog\",\n \"fields\": [\n { \"name\": \"title\", \"type\": \"text\", \"required\": true },\n { \"name\": \"body\", \"type\": \"richtext\", \"required\": true },\n { \"name\": \"external\", \"type\": \"text\", \"required\": false },\n { \"name\": \"hero\", \"type\": \"asset\", \"required\": false }\n ],\n \"localized\": true\n }\n}\n```\n\nThe `external` field is your import key — store the source system's id here so re-runs are idempotent.\n\nIf the collection already exists but needs new fields:\n\n```jsonc\n{\n \"name\": \"cms_update_collection\",\n \"arguments\": { \"idOrSlug\": \"blog\", \"patch\": { \"fields\": [...new full list...] } }\n}\n```\n\nField changes are partly lossy — dropped or renamed fields remain in entries' `data` jsonb but aren't projected through the API. The data isn't deleted; it's just hidden.\n\n## Step 2 — idempotency key\n\nDecide on the source identifier (e.g. Contentful entry id, WordPress post id) and treat it as your import key. Every imported entry gets `data.external = \"\"`. Before creating, look it up:\n\n```jsonc\n{\n \"name\": \"cms_search\",\n \"arguments\": { \"query\": \"\", \"collection\": \"blog\", \"limit\": 5 }\n}\n```\n\nIf the search returns a hit and the hit's `data.external` matches exactly, it's the existing entry — update it instead of creating a new one.\n\n## Step 3 — per-row import loop\n\nPseudocode:\n\n```\nfor row in source:\n hits = cms_search(query=row.id, collection=\"blog\")\n existing = first hit where data.external == row.id\n\n payload = {\n title: row.title,\n body: transform(row.body),\n external: row.id,\n hero: \n }\n\n if existing:\n cms_get_entry(existing.id) -> { version }\n cms_update_entry(existing.id, ifVersion=version, data=payload)\n else:\n cms_create_entry(collection=\"blog\", slug=row.slug, locale=row.locale, data=payload, status=\"draft\")\n```\n\nFor assets referenced inline (images in body, hero images): use `skill://cms/upload-asset-and-embed` to upload, then put the new asset id in the entry's `data`. Build a `sourceAssetUrl → muninAssetId` map as you go so you don't re-upload duplicates.\n\nFor body content with rich-text references to other entries (cross-links), defer until step 4.\n\n## Step 4 — second pass: rewire references\n\nAfter every entry exists, walk the body fields and replace external links with internal entry ids. For each entry:\n\n```jsonc\n{ \"name\": \"cms_get_entry\", \"arguments\": { \"id\": \"\" } }\n```\n\nRewrite the body so any `` becomes a Munin entry reference (resolve via `cms_search` on the source-id-mapped entry). Then:\n\n```jsonc\n{\n \"name\": \"cms_update_entry\",\n \"arguments\": { \"id\": \"\", \"ifVersion\": , \"data\": {...rewritten...} }\n}\n```\n\nVerify with `cms_list_inbound_references` on a few entries — outbound links from this entry should now show as inbound references on the target entries.\n\n## Step 5 — verify and publish\n\n```jsonc\n{ \"name\": \"cms_list_entries\", \"arguments\": { \"collection\": \"blog\", \"limit\": 200 } }\n```\n\nSanity-check `returned` against the source count. Entries come back as summaries with long text shortened, so body fidelity needs `cms_get_entry` on 2–3 spot checks — or `cms_list_entries` with `\"fields\": [\"body\"]` on a handful of `ids` to compare several at once. Then publish in batches per `skill://cms/publish-entry`.\n\n## What NOT to do\n\n- **Don't run without an idempotency key.** A failed mid-run import that re-runs without a key creates duplicates with the same slug + locale, which fails uniqueness. With a key, the second run updates instead.\n- **Don't skip the second pass.** Cross-links in body content will resolve to dead URLs unless rewritten.\n- **Don't delete the destination collection to \"start over\" on a stuck import.** `cms_delete_collection` cascades to entries and their versions, plus any inbound references break. Update the schema instead, or import into a new collection slug.\n- **Don't bulk-publish without spot checks.** Publishing all-at-once means a malformed transformation hits production for every entry simultaneously.\n\n## Related\n\n- `skill://cms/publish-entry` — publishing entries after import.\n- `skill://cms/upload-asset-and-embed` — uploading images referenced from imported content.\n- `skill://cms/localize-entry` — when source content is per-locale.\n" + "content": "# Migrate content in bulk\nImporting a customer's existing content (Webflow, Contentful, headless WordPress, an old Munin collection) into a Munin CMS collection. Designed to be **idempotent** so a partial run can resume.\n\n## TL;DR\n\n1. Inspect or design the destination collection schema (`cms_get_collection` / `cms_create_collection`).\n2. Pick a stable external id and store it in a custom field so re-runs find existing entries via `cms_search_entries`.\n3. For each source row: search for an existing entry → create new or update existing.\n4. Rewire references after all entries are imported (two-pass).\n5. Verify count, spot-check a few entries.\n\n## Step 1 — destination schema\n\nIf migrating into a new collection:\n\n```jsonc\n{ \"name\": \"cms_list_collections\", \"arguments\": {} }\n```\n\n```jsonc\n{\n \"name\": \"cms_create_collection\",\n \"arguments\": {\n \"name\": \"Blog\",\n \"slug\": \"blog\",\n \"fields\": [\n { \"name\": \"title\", \"type\": \"text\", \"required\": true },\n { \"name\": \"body\", \"type\": \"richtext\", \"required\": true },\n { \"name\": \"external\", \"type\": \"text\", \"required\": false },\n { \"name\": \"hero\", \"type\": \"asset\", \"required\": false }\n ],\n \"localized\": true\n }\n}\n```\n\nThe `external` field is your import key — store the source system's id here so re-runs are idempotent.\n\nIf the collection already exists but needs new fields:\n\n```jsonc\n{\n \"name\": \"cms_update_collection\",\n \"arguments\": { \"idOrSlug\": \"blog\", \"patch\": { \"fields\": [...new full list...] } }\n}\n```\n\nField changes are partly lossy — dropped or renamed fields remain in entries' `data` jsonb but aren't projected through the API. The data isn't deleted; it's just hidden.\n\n## Step 2 — idempotency key\n\nDecide on the source identifier (e.g. Contentful entry id, WordPress post id) and treat it as your import key. Every imported entry gets `data.external = \"\"`. Before creating, look it up:\n\n```jsonc\n{\n \"name\": \"cms_search_entries\",\n \"arguments\": { \"query\": \"\", \"collection\": \"blog\", \"limit\": 5 }\n}\n```\n\nIf the search returns a hit and the hit's `data.external` matches exactly, it's the existing entry — update it instead of creating a new one.\n\n## Step 3 — per-row import loop\n\nPseudocode:\n\n```\nfor row in source:\n hits = cms_search_entries(query=row.id, collection=\"blog\")\n existing = first hit where data.external == row.id\n\n payload = {\n title: row.title,\n body: transform(row.body),\n external: row.id,\n hero: \n }\n\n if existing:\n cms_get_entry(existing.id) -> { version }\n cms_update_entry(existing.id, ifVersion=version, data=payload)\n else:\n cms_create_entry(collection=\"blog\", slug=row.slug, locale=row.locale, data=payload, status=\"draft\")\n```\n\nFor assets referenced inline (images in body, hero images): use `skill://cms/upload-asset-and-embed` to upload, then put the new asset id in the entry's `data`. Build a `sourceAssetUrl → muninAssetId` map as you go so you don't re-upload duplicates.\n\nFor body content with rich-text references to other entries (cross-links), defer until step 4.\n\n## Step 4 — second pass: rewire references\n\nAfter every entry exists, walk the body fields and replace external links with internal entry ids. For each entry:\n\n```jsonc\n{ \"name\": \"cms_get_entry\", \"arguments\": { \"id\": \"\" } }\n```\n\nRewrite the body so any `` becomes a Munin entry reference (resolve via `cms_search_entries` on the source-id-mapped entry). Then:\n\n```jsonc\n{\n \"name\": \"cms_update_entry\",\n \"arguments\": { \"id\": \"\", \"ifVersion\": , \"data\": {...rewritten...} }\n}\n```\n\nVerify with `cms_list_inbound_references` on a few entries — outbound links from this entry should now show as inbound references on the target entries.\n\n## Step 5 — verify and publish\n\n```jsonc\n{ \"name\": \"cms_list_entries\", \"arguments\": { \"collection\": \"blog\", \"limit\": 200 } }\n```\n\nSanity-check `returned` against the source count. Entries come back as summaries with long text shortened, so body fidelity needs `cms_get_entry` on 2–3 spot checks — or `cms_list_entries` with `\"fields\": [\"body\"]` on a handful of `ids` to compare several at once. Then publish in batches per `skill://cms/publish-entry`.\n\n## What NOT to do\n\n- **Don't run without an idempotency key.** A failed mid-run import that re-runs without a key creates duplicates with the same slug + locale, which fails uniqueness. With a key, the second run updates instead.\n- **Don't skip the second pass.** Cross-links in body content will resolve to dead URLs unless rewritten.\n- **Don't delete the destination collection to \"start over\" on a stuck import.** `cms_delete_collection` cascades to entries and their versions, plus any inbound references break. Update the schema instead, or import into a new collection slug.\n- **Don't bulk-publish without spot checks.** Publishing all-at-once means a malformed transformation hits production for every entry simultaneously.\n\n## Related\n\n- `skill://cms/publish-entry` — publishing entries after import.\n- `skill://cms/upload-asset-and-embed` — uploading images referenced from imported content.\n- `skill://cms/localize-entry` — when source content is per-locale.\n" }, { "uri": "skill://cms/preview-entry", @@ -96,7 +96,7 @@ "title": "CMS: Review stale entries", "description": "Periodic curator pass — find drafts that have stalled, published entries that haven't been touched in months, and orphaned assets. Reports findings with recommended actions. No persistence layer; the operator reviews the curator-runner's log/reply and acts manually via existing CMS tools.", "mimeType": "text/markdown", - "content": "# Review stale entries\nCMS data accumulates: drafts that someone started but never published, articles that were great two years ago but reference a product that's been retired, asset uploads from a campaign that ended last quarter. None of this is wrong individually — together it makes search worse, makes the editorial team trust the CMS less, and makes \"is this current?\" the first question every consumer asks.\n\nThis skill walks an admin agent through one periodic stale-content pass. The agent finds suspect items, judges each one against the org's velocity and content style, and produces a structured report. There's no proposal queue or merge-table for v1 — the report goes back as the curator-runner's reply, the operator reads it, and they (or their admin agent) act on it via the existing `cms_*` tools.\n\nRun periodically. Quarterly is a good default for content-stable orgs; monthly for fast-moving ones. Don't run inline per CMS write — staleness is a population property, not a per-row property.\n\n## TL;DR\n\n1. **Inventory collections** with `cms_list_collections` so you know the velocity expectations of each (a docs collection is updated less often than a \"weekly product update\" collection).\n2. **Find stale drafts** — `cms_list_entries({ status: \"draft\" })`, filter to `updatedAt > 30 days ago`, and judge each one (\"is this clearly abandoned?\" vs \"is this work in progress that just got blocked?\").\n3. **Find stale published entries** — `cms_list_entries({ status: \"published\" })`, filter to `updatedAt > N months ago` per collection's velocity. For each, run `cms_list_inbound_references` to see if the entry is still load-bearing or has been superseded.\n4. **Find orphaned assets** — `cms_list_assets`, cross-reference against entry bodies (search for asset IDs in entries' `data` payloads) to find uploads no entry points to.\n5. **Compose a structured report** grouped by recommended action: `archive`, `refresh`, `delete-asset`, `keep`. Include enough evidence per item that a reviewer can decide without re-querying.\n6. **Stop.** The operator reviews the report (your reply-to-the-user output for ad-hoc runs, or the scheduled-runner log if you're invoked from a cron) and acts on it manually using the existing `cms_*` tools.\n\n## Step 1 — collections inventory\n\n```jsonc\n{ \"name\": \"cms_list_collections\", \"arguments\": {} }\n```\n\nFor each collection, infer a *velocity expectation* from its purpose:\n\n- **Reference / docs / FAQ** — published entries should be reviewed at least every 6 months; drafts older than 60 days are suspicious.\n- **News / weekly update / changelog** — published entries become stale fast (the news from 2 years ago isn't relevant); drafts older than 14 days are almost certainly abandoned.\n- **Marketing landing / campaign** — tied to specific dates; check the entry body for date references that have passed.\n- **Settings / configuration entries** — should be touched only when something changes; long stability is a feature, not staleness.\n\nMake the velocity threshold per-collection in your judgment. Don't apply one global cutoff — a 14-month-old \"About us\" page is fine, a 14-month-old \"Q2 2024 promotions\" page is not.\n\n## Step 2 — stale drafts\n\n```jsonc\n{ \"name\": \"cms_list_entries\", \"arguments\": { \"status\": \"draft\", \"limit\": 200 } }\n```\n\nThe result is `{ entries, returned, dropped, truncated }`. Each entry is a **summary**, not the full record: short fields come back verbatim, long text is shortened to a ~200-character lead, and `fieldSummary` carries the withheld detail — `{ \"body\": { \"words\": 1600, \"truncated\": true } }` for shortened prose, `{ \"items\": 12, \"omitted\": true }` for a large collection field. `truncated: true` on an entry means some values were withheld; `cms_get_entry` returns that entry whole. A non-zero `dropped` means rows were withheld to stay inside the result budget — narrow the filters or lower `limit` and page through.\n\nPrefer `fieldSummary..words` over eyeballing the lead when you need a length signal — it is the count for the whole field, not the lead.\n\nFor each draft older than the collection's threshold, decide:\n\n- **Abandoned** — recommend `archive` or `delete`. Triggers: same author hasn't touched it in 60+ days; a very short body (`fieldSummary.body.words` under ~40, or a lead that is clearly the whole field) suggesting it never got going; title suggests an event/launch that has passed.\n- **Work in progress** — leave alone. Triggers: long body actively being edited (use `cms_list_versions` to see edit velocity); recent author activity in other entries.\n- **Stuck on a blocker** — leave alone but flag for the reviewer to ping the author.\n\n## Step 3 — stale published entries\n\n```jsonc\n{ \"name\": \"cms_list_entries\", \"arguments\": { \"status\": \"published\", \"limit\": 200 } }\n```\n\nFor each entry older than the collection's threshold (per Step 1's per-collection velocity), check inbound references *and* reader engagement (if the delivery API is tracked — see `skill://analytics/track-website-traffic`):\n\n```jsonc\n{ \"name\": \"cms_list_inbound_references\", \"arguments\": { \"entryId\": \"cme_...\" } }\n```\n\n```jsonc\n{ \"name\": \"analytics_get_subject_engagement\",\n \"arguments\": { \"subjectType\": \"cms_entry\", \"subjectId\": \"cme_...\", \"sinceDays\": 90 } }\n```\n\nResponse: `{ views, visitors, avgDwellMs, avgReadDepth, lastViewAt }`. If analytics isn't wired up the values come back as 0 / null — fall back to the inbound-references signal alone.\n\nThen judge — combine the structural signal (inbound refs) with the behavioral signal (views/dwell):\n\n- **Still load-bearing** — many inbound references *and* meaningful views in the last 90 days. Recommend `refresh` (the operator should re-read it for accuracy) rather than archiving.\n- **Quietly important** — few inbound references but consistent traffic. Recommend `refresh` — readers find it via search/direct, the graph just doesn't reflect that.\n- **Superseded but referenced** — newer entries cover the same topic and are linked-to in the same neighborhood; views are trending down. Recommend `archive` (`cms_unpublish_entry`) but flag the inbound references so the operator can decide whether to redirect or delete them.\n- **Orphaned** — no inbound references, no recent views, and `updatedAt` very old. Recommend `delete` (`cms_delete_entry`).\n- **Time-bound** — title or body references a date/event that has passed (campaign, version-specific docs). Recommend `archive` or `delete` with high confidence regardless of view count (residual traffic to expired content is usually a sign of stale external links, not value).\n\nA useful complement: `analytics_list_zero_result_searches({sinceDays: 90})` surfaces the queries readers asked that returned nothing. If a stale entry is the closest match to a frequent zero-result query, that's a strong \"refresh + retitle\" signal — readers want this content, they just can't find it.\n\n## Step 4 — orphaned assets\n\n```jsonc\n{ \"name\": \"cms_list_assets\", \"arguments\": { \"limit\": 200 } }\n```\n\nFor each asset, check whether any entry references it. Asset references typically appear inside entry `data` payloads (image fields, gallery fields, file uploads). The skill doesn't have a direct \"list inbound references for this asset\" tool — instead:\n\n- Use `cms_search` with the asset id as the query to find entries that mention it.\n- Or if the asset's filename is distinctive, search by filename.\n\nIf no entries reference the asset *and* `createdAt` is more than 90 days old, recommend `delete-asset` (`cms_delete_asset`). Be careful with recently uploaded assets — they may be tied to a draft entry that's still in progress.\n\n## Step 5 — compose the report\n\nStructure the output so an operator can scan it. Group by recommended action, not by collection. Per item: id, title, collection, last-updated date, evidence, recommended action. Example:\n\n```markdown\n## CMS stale-content review — 2026-05-04\n\n### Archive (low-risk)\n\n- **cme_abc123** — *\"Q4 2024 holiday promotions\"* (campaigns) — last updated 2024-11-15\n - Time-bound title, holiday already passed; 0 inbound references\n - Action: `cms_unpublish_entry({ id: \"cme_abc123\" })`, then `cms_delete_entry` after a 30-day soft window\n\n### Refresh (still load-bearing, but stale)\n\n- **cme_def456** — *\"Pricing\"* (top-level) — last updated 2024-08-12\n - 23 inbound references; in main navigation\n - Action: re-read for accuracy; verify pricing tiers haven't changed; bump `updatedAt`\n\n### Delete (orphaned drafts)\n\n- **cme_ghi789** — *\"Untitled draft\"* (blog) — last updated 2025-09-01\n - Body length 47 chars; never published; same author has not edited in 8 months\n - Action: `cms_delete_entry({ id: \"cme_ghi789\" })`\n\n### Delete asset (orphaned upload)\n\n- **cma_jkl012** — *\"campaign-banner-q3.png\"* — uploaded 2024-06-20\n - No entries reference this filename; not in any draft\n - Action: `cms_delete_asset({ id: \"cma_jkl012\" })`\n\n### Keep (stable on purpose)\n\n- **cme_mno345** — *\"About us\"* (top-level) — last updated 2023-04-10\n - Despite age, content is stable by design (org history); 12 inbound references\n - Action: none (annotated for the next pass to skip)\n```\n\nThe operator reviews this report and runs the recommended commands. None of the recommendations execute automatically.\n\n## What NOT to do\n\n- **Don't auto-execute.** v1 of this skill is propose-only. Even `delete` on an orphaned draft is the operator's call — the agent might be wrong about what's load-bearing.\n- **Don't apply one global staleness threshold.** A 14-month-old \"About us\" page is healthy; a 14-month-old \"Q2 2024 promotions\" page is not. Per-collection velocity in Step 1 is the whole point.\n- **Don't recommend deleting entries with active inbound references** without flagging the references explicitly — silent breakage is worse than visible staleness.\n- **Don't recurse into the full-text content of every published entry on every pass.** The pass should be cheap. Sample, prioritize obvious cases, and let next quarter's pass cover what this one didn't.\n- **Don't include private end-user data in the report** (a stale entry's body might contain customer names, account ids, etc.). Reference items by id + title; let the reviewer open them in context.\n\n## Future work\n\n- A `cms_curation_proposals` table (mirroring `crm_merge_proposals`) so this skill produces a persistent review queue instead of a one-shot report. Add when the volume justifies it.\n- Per-asset inbound-reference check (a dedicated tool that walks all entries' `data` for asset id mentions). Today the skill uses `cms_search` as a workaround.\n- Automated `cms_unpublish_entry` for the high-confidence \"time-bound, expired, zero references\" subset, gated behind an explicit org-level toggle.\n\n## Related\n\n- `skill://kb/review-content` — sibling curator pass for conversation → KB document candidates. Different domain, similar \"propose, don't apply\" philosophy. KB curation has a persistent inbox; CMS stale-content review v1 does not.\n- `skill://crm/clean-contact-data` — sibling curator pass for CRM merge proposals. Different domain, structured proposals table.\n" + "content": "# Review stale entries\nCMS data accumulates: drafts that someone started but never published, articles that were great two years ago but reference a product that's been retired, asset uploads from a campaign that ended last quarter. None of this is wrong individually — together it makes search worse, makes the editorial team trust the CMS less, and makes \"is this current?\" the first question every consumer asks.\n\nThis skill walks an admin agent through one periodic stale-content pass. The agent finds suspect items, judges each one against the org's velocity and content style, and produces a structured report. There's no proposal queue or merge-table for v1 — the report goes back as the curator-runner's reply, the operator reads it, and they (or their admin agent) act on it via the existing `cms_*` tools.\n\nRun periodically. Quarterly is a good default for content-stable orgs; monthly for fast-moving ones. Don't run inline per CMS write — staleness is a population property, not a per-row property.\n\n## TL;DR\n\n1. **Inventory collections** with `cms_list_collections` so you know the velocity expectations of each (a docs collection is updated less often than a \"weekly product update\" collection).\n2. **Find stale drafts** — `cms_list_entries({ status: \"draft\" })`, filter to `updatedAt > 30 days ago`, and judge each one (\"is this clearly abandoned?\" vs \"is this work in progress that just got blocked?\").\n3. **Find stale published entries** — `cms_list_entries({ status: \"published\" })`, filter to `updatedAt > N months ago` per collection's velocity. For each, run `cms_list_inbound_references` to see if the entry is still load-bearing or has been superseded.\n4. **Find orphaned assets** — `cms_list_assets`, cross-reference against entry bodies (search for asset IDs in entries' `data` payloads) to find uploads no entry points to.\n5. **Compose a structured report** grouped by recommended action: `archive`, `refresh`, `delete-asset`, `keep`. Include enough evidence per item that a reviewer can decide without re-querying.\n6. **Stop.** The operator reviews the report (your reply-to-the-user output for ad-hoc runs, or the scheduled-runner log if you're invoked from a cron) and acts on it manually using the existing `cms_*` tools.\n\n## Step 1 — collections inventory\n\n```jsonc\n{ \"name\": \"cms_list_collections\", \"arguments\": {} }\n```\n\nFor each collection, infer a *velocity expectation* from its purpose:\n\n- **Reference / docs / FAQ** — published entries should be reviewed at least every 6 months; drafts older than 60 days are suspicious.\n- **News / weekly update / changelog** — published entries become stale fast (the news from 2 years ago isn't relevant); drafts older than 14 days are almost certainly abandoned.\n- **Marketing landing / campaign** — tied to specific dates; check the entry body for date references that have passed.\n- **Settings / configuration entries** — should be touched only when something changes; long stability is a feature, not staleness.\n\nMake the velocity threshold per-collection in your judgment. Don't apply one global cutoff — a 14-month-old \"About us\" page is fine, a 14-month-old \"Q2 2024 promotions\" page is not.\n\n## Step 2 — stale drafts\n\n```jsonc\n{ \"name\": \"cms_list_entries\", \"arguments\": { \"status\": \"draft\", \"limit\": 200 } }\n```\n\nThe result is `{ entries, returned, dropped, truncated }`. Each entry is a **summary**, not the full record: short fields come back verbatim, long text is shortened to a ~200-character lead, and `fieldSummary` carries the withheld detail — `{ \"body\": { \"words\": 1600, \"truncated\": true } }` for shortened prose, `{ \"items\": 12, \"omitted\": true }` for a large collection field. `truncated: true` on an entry means some values were withheld; `cms_get_entry` returns that entry whole. A non-zero `dropped` means rows were withheld to stay inside the result budget — narrow the filters or lower `limit` and page through.\n\nPrefer `fieldSummary..words` over eyeballing the lead when you need a length signal — it is the count for the whole field, not the lead.\n\nFor each draft older than the collection's threshold, decide:\n\n- **Abandoned** — recommend `archive` or `delete`. Triggers: same author hasn't touched it in 60+ days; a very short body (`fieldSummary.body.words` under ~40, or a lead that is clearly the whole field) suggesting it never got going; title suggests an event/launch that has passed.\n- **Work in progress** — leave alone. Triggers: long body actively being edited (use `cms_list_versions` to see edit velocity); recent author activity in other entries.\n- **Stuck on a blocker** — leave alone but flag for the reviewer to ping the author.\n\n## Step 3 — stale published entries\n\n```jsonc\n{ \"name\": \"cms_list_entries\", \"arguments\": { \"status\": \"published\", \"limit\": 200 } }\n```\n\nFor each entry older than the collection's threshold (per Step 1's per-collection velocity), check inbound references *and* reader engagement (if the delivery API is tracked — see `skill://analytics/track-website-traffic`):\n\n```jsonc\n{ \"name\": \"cms_list_inbound_references\", \"arguments\": { \"entryId\": \"cme_...\" } }\n```\n\n```jsonc\n{ \"name\": \"analytics_get_subject_engagement\",\n \"arguments\": { \"subjectType\": \"cms_entry\", \"subjectId\": \"cme_...\", \"sinceDays\": 90 } }\n```\n\nResponse: `{ views, visitors, avgDwellMs, avgReadDepth, lastViewAt }`. If analytics isn't wired up the values come back as 0 / null — fall back to the inbound-references signal alone.\n\nThen judge — combine the structural signal (inbound refs) with the behavioral signal (views/dwell):\n\n- **Still load-bearing** — many inbound references *and* meaningful views in the last 90 days. Recommend `refresh` (the operator should re-read it for accuracy) rather than archiving.\n- **Quietly important** — few inbound references but consistent traffic. Recommend `refresh` — readers find it via search/direct, the graph just doesn't reflect that.\n- **Superseded but referenced** — newer entries cover the same topic and are linked-to in the same neighborhood; views are trending down. Recommend `archive` (`cms_unpublish_entry`) but flag the inbound references so the operator can decide whether to redirect or delete them.\n- **Orphaned** — no inbound references, no recent views, and `updatedAt` very old. Recommend `delete` (`cms_delete_entry`).\n- **Time-bound** — title or body references a date/event that has passed (campaign, version-specific docs). Recommend `archive` or `delete` with high confidence regardless of view count (residual traffic to expired content is usually a sign of stale external links, not value).\n\nA useful complement: `analytics_list_zero_result_searches({sinceDays: 90})` surfaces the queries readers asked that returned nothing. If a stale entry is the closest match to a frequent zero-result query, that's a strong \"refresh + retitle\" signal — readers want this content, they just can't find it.\n\n## Step 4 — orphaned assets\n\n```jsonc\n{ \"name\": \"cms_list_assets\", \"arguments\": { \"limit\": 200 } }\n```\n\nFor each asset, check whether any entry references it. Asset references typically appear inside entry `data` payloads (image fields, gallery fields, file uploads). The skill doesn't have a direct \"list inbound references for this asset\" tool — instead:\n\n- Use `cms_search_entries` with the asset id as the query to find entries that mention it.\n- Or if the asset's filename is distinctive, search by filename.\n\nIf no entries reference the asset *and* `createdAt` is more than 90 days old, recommend `delete-asset` (`cms_delete_asset`). Be careful with recently uploaded assets — they may be tied to a draft entry that's still in progress.\n\n## Step 5 — compose the report\n\nStructure the output so an operator can scan it. Group by recommended action, not by collection. Per item: id, title, collection, last-updated date, evidence, recommended action. Example:\n\n```markdown\n## CMS stale-content review — 2026-05-04\n\n### Archive (low-risk)\n\n- **cme_abc123** — *\"Q4 2024 holiday promotions\"* (campaigns) — last updated 2024-11-15\n - Time-bound title, holiday already passed; 0 inbound references\n - Action: `cms_unpublish_entry({ id: \"cme_abc123\" })`, then `cms_delete_entry` after a 30-day soft window\n\n### Refresh (still load-bearing, but stale)\n\n- **cme_def456** — *\"Pricing\"* (top-level) — last updated 2024-08-12\n - 23 inbound references; in main navigation\n - Action: re-read for accuracy; verify pricing tiers haven't changed; bump `updatedAt`\n\n### Delete (orphaned drafts)\n\n- **cme_ghi789** — *\"Untitled draft\"* (blog) — last updated 2025-09-01\n - Body length 47 chars; never published; same author has not edited in 8 months\n - Action: `cms_delete_entry({ id: \"cme_ghi789\" })`\n\n### Delete asset (orphaned upload)\n\n- **cma_jkl012** — *\"campaign-banner-q3.png\"* — uploaded 2024-06-20\n - No entries reference this filename; not in any draft\n - Action: `cms_delete_asset({ id: \"cma_jkl012\" })`\n\n### Keep (stable on purpose)\n\n- **cme_mno345** — *\"About us\"* (top-level) — last updated 2023-04-10\n - Despite age, content is stable by design (org history); 12 inbound references\n - Action: none (annotated for the next pass to skip)\n```\n\nThe operator reviews this report and runs the recommended commands. None of the recommendations execute automatically.\n\n## What NOT to do\n\n- **Don't auto-execute.** v1 of this skill is propose-only. Even `delete` on an orphaned draft is the operator's call — the agent might be wrong about what's load-bearing.\n- **Don't apply one global staleness threshold.** A 14-month-old \"About us\" page is healthy; a 14-month-old \"Q2 2024 promotions\" page is not. Per-collection velocity in Step 1 is the whole point.\n- **Don't recommend deleting entries with active inbound references** without flagging the references explicitly — silent breakage is worse than visible staleness.\n- **Don't recurse into the full-text content of every published entry on every pass.** The pass should be cheap. Sample, prioritize obvious cases, and let next quarter's pass cover what this one didn't.\n- **Don't include private end-user data in the report** (a stale entry's body might contain customer names, account ids, etc.). Reference items by id + title; let the reviewer open them in context.\n\n## Future work\n\n- A `cms_curation_proposals` table (mirroring `crm_merge_proposals`) so this skill produces a persistent review queue instead of a one-shot report. Add when the volume justifies it.\n- Per-asset inbound-reference check (a dedicated tool that walks all entries' `data` for asset id mentions). Today the skill uses `cms_search_entries` as a workaround.\n- Automated `cms_unpublish_entry` for the high-confidence \"time-bound, expired, zero references\" subset, gated behind an explicit org-level toggle.\n\n## Related\n\n- `skill://kb/review-content` — sibling curator pass for conversation → KB document candidates. Different domain, similar \"propose, don't apply\" philosophy. KB curation has a persistent inbox; CMS stale-content review v1 does not.\n- `skill://crm/clean-contact-data` — sibling curator pass for CRM merge proposals. Different domain, structured proposals table.\n" }, { "uri": "skill://cms/upload-asset-and-embed", @@ -105,7 +105,7 @@ "title": "CMS: Upload an asset and embed it", "description": "Two-phase asset upload (request presigned upload → send binary → complete), embed in entries, and audit unused assets.", "mimeType": "text/markdown", - "content": "# Upload an asset and embed it\nCMS assets are uploaded out-of-band: the server hands you a **presigned upload**, you send the binary directly to object storage, then you tell the server the upload is done. This avoids streaming binaries through the MCP/HTTP layer. The exact HTTP shape depends on the storage backend — read `uploadMethod` and branch.\n\n## TL;DR\n\n1. `cms_request_asset_upload` — server creates an `uploaded: false` row, returns `uploadUrl`, `uploadMethod`, `uploadFields`, `uploadExpiresAt`.\n2. Send the binary to `uploadUrl` using `uploadMethod` (PUT raw body for local self-host; POST multipart for S3, including every field from `uploadFields` plus a `file` part). On S3 the embedded policy enforces `Content-Length-Range` so an oversized body is rejected by the bucket itself.\n3. `cms_complete_asset_upload` — verifies the on-storage size matches what was declared, then flips `uploaded: true`. On size mismatch the storage object is deleted; the row stays at `uploaded:false` and you can retry from step 1.\n4. Embed by writing the asset id into an entry's `data` field via `cms_update_entry`.\n\n## Step 1 — request the upload\n\n```jsonc\n{\n \"name\": \"cms_request_asset_upload\",\n \"arguments\": {\n \"name\": \"spring-launch-hero.jpg\",\n \"mime\": \"image/jpeg\",\n \"sizeBytes\": 482301,\n \"altText\": \"Spring launch hero image — three product shots on a sunny patio.\",\n \"metadata\": { \"campaign\": \"spring-2026\" }\n }\n}\n```\n\nResponse (PUT-style, local self-host):\n```jsonc\n{\n \"id\": \"\",\n \"name\": \"spring-launch-hero.jpg\",\n \"mime\": \"image/jpeg\",\n \"sizeBytes\": 482301,\n \"uploaded\": false,\n \"uploadUrl\": \"http://localhost:3001/static/assets/upload?key=...&sig=...\",\n \"uploadMethod\": \"PUT\",\n \"uploadFields\": {},\n \"uploadExpiresAt\": \"2026-05-01T12:34:56Z\"\n}\n```\n\nResponse (POST-style, S3):\n```jsonc\n{\n \"id\": \"\",\n \"uploadUrl\": \"https://s3.example.com/bucket/\",\n \"uploadMethod\": \"POST\",\n \"uploadFields\": {\n \"key\": \"org_x/abc.jpg\",\n \"Content-Type\": \"image/jpeg\",\n \"policy\": \"\",\n \"x-amz-algorithm\": \"AWS4-HMAC-SHA256\",\n \"x-amz-credential\": \"AKIA.../20260501/eu-west-1/s3/aws4_request\",\n \"x-amz-date\": \"20260501T123456Z\",\n \"x-amz-signature\": \"\"\n },\n \"uploadExpiresAt\": \"2026-05-01T12:34:56Z\"\n}\n```\n\n## Step 2 — send the binary\n\nIf `uploadMethod === \"PUT\"`:\n\n```bash\ncurl --upload-file ./spring-launch-hero.jpg \\\n -H \"Content-Type: image/jpeg\" \\\n \"\"\n```\n\nIf `uploadMethod === \"POST\"`:\n\n```bash\ncurl -X POST \"\" \\\n $(printf -- '-F %s=%s ' key \"$KEY\" Content-Type image/jpeg policy \"$POLICY\" x-amz-algorithm AWS4-HMAC-SHA256 ...) \\\n -F \"file=@./spring-launch-hero.jpg\"\n```\n\nThe `file` part must come **last** in the multipart body. Send every key in `uploadFields` as a form field; the embedded policy fixes the allowed byte size to exactly `sizeBytes`, so an oversized body is rejected at S3. Must complete before `uploadExpiresAt` (typically ~15 min).\n\n## Step 3 — complete\n\n```jsonc\n{ \"name\": \"cms_complete_asset_upload\", \"arguments\": { \"id\": \"\" } }\n```\n\nFlips `uploaded: true`. Until you call this, the asset is invisible to other tools and entries can't reference it. You can call complete **as long as the row exists** — the URL expires, but the row doesn't.\n\n## Step 4 — embed in an entry\n\nThere are two ways to embed an asset, depending on whether it's a standalone field (a cover image, a gallery) or an image placed within prose. Both also work *inside a block* (`skill://cms/author-with-blocks`) — an asset used in a block, as a typed prop or an inline `asset://` token in block prose, is tracked the same way and is covered by the delete guard below.\n\n### As a typed field\n\nA field whose collection type is `asset` (or `array` of `asset`) stores the asset id. Read the entry, write the field, send the update:\n\n```jsonc\n{ \"name\": \"cms_get_entry\", \"arguments\": { \"id\": \"\" } }\n```\n\n```jsonc\n{\n \"name\": \"cms_update_entry\",\n \"arguments\": {\n \"id\": \"\",\n \"ifVersion\": 12,\n \"data\": { \"...all other fields...\": \"...\", \"heroImage\": \"\" }\n }\n}\n```\n\n### Inline in a markdown/rich_text body\n\nTo place an image within the prose of a `markdown` or `rich_text` field, embed an `asset://` reference — in markdown, as the URL of an image:\n\n```markdown\nIntro paragraph.\n\n![Spring launch hero](asset://)\n\nMore copy.\n```\n\nThe asset must already be uploaded (`uploaded: true`) — an inline reference to an unknown or unconfirmed asset is rejected when you create/update the entry. On read, the delivery API and `cms_get_entry`/`cms_search` rewrite each `asset://` to the asset's `publicUrl` and attach an `_assets` map keyed by asset id so you can also read `altText`, `mime`, and `sizeBytes`.\n\n(Use `skill://cms/publish-entry` for the full update + publish dance.)\n\n## Auditing unused assets\n\n```jsonc\n{ \"name\": \"cms_list_assets\", \"arguments\": { \"limit\": 200 } }\n```\n\nTo see which entries use a given asset — as a typed field or inline in a body — call:\n\n```jsonc\n{ \"name\": \"cms_list_asset_usage\", \"arguments\": { \"assetId\": \"\" } }\n```\n\nIt returns one row per reference with `fromEntryId`, `fieldName`, and `kind` (`field` or `inline`). An empty result means the asset is safe to delete.\n\n## What NOT to do\n\n- **Don't call `cms_complete_asset_upload` before the binary PUT succeeds.** The asset will be marked `uploaded: true` with no actual file — entries referencing it will render broken.\n- **Don't lose the `id`.** Without it you can't complete the upload, and the half-uploaded row sits as an orphan (no automatic GC).\n- **Don't reuse one presigned URL for multiple files.** Each `cms_request_asset_upload` mints a new URL bound to the size and mime you declared.\n- **Don't expect to delete an asset that's still in use.** `cms_delete_asset` fails with a conflict while any entry references the asset (field or inline). Call `cms_list_asset_usage` first, then remove the references from those entries before deleting.\n\n## Related\n\n- `skill://cms/publish-entry` — the update + publish dance for the entry that embeds the asset.\n- `skill://cms/migrate-content` — when you're moving assets along with entries.\n" + "content": "# Upload an asset and embed it\nCMS assets are uploaded out-of-band: the server hands you a **presigned upload**, you send the binary directly to object storage, then you tell the server the upload is done. This avoids streaming binaries through the MCP/HTTP layer. The exact HTTP shape depends on the storage backend — read `uploadMethod` and branch.\n\n## TL;DR\n\n1. `cms_request_asset_upload` — server creates an `uploaded: false` row, returns `uploadUrl`, `uploadMethod`, `uploadFields`, `uploadExpiresAt`.\n2. Send the binary to `uploadUrl` using `uploadMethod` (PUT raw body for local self-host; POST multipart for S3, including every field from `uploadFields` plus a `file` part). On S3 the embedded policy enforces `Content-Length-Range` so an oversized body is rejected by the bucket itself.\n3. `cms_complete_asset_upload` — verifies the on-storage size matches what was declared, then flips `uploaded: true`. On size mismatch the storage object is deleted; the row stays at `uploaded:false` and you can retry from step 1.\n4. Embed by writing the asset id into an entry's `data` field via `cms_update_entry`.\n\n## Step 1 — request the upload\n\n```jsonc\n{\n \"name\": \"cms_request_asset_upload\",\n \"arguments\": {\n \"name\": \"spring-launch-hero.jpg\",\n \"mime\": \"image/jpeg\",\n \"sizeBytes\": 482301,\n \"altText\": \"Spring launch hero image — three product shots on a sunny patio.\",\n \"metadata\": { \"campaign\": \"spring-2026\" }\n }\n}\n```\n\nResponse (PUT-style, local self-host):\n```jsonc\n{\n \"id\": \"\",\n \"name\": \"spring-launch-hero.jpg\",\n \"mime\": \"image/jpeg\",\n \"sizeBytes\": 482301,\n \"uploaded\": false,\n \"uploadUrl\": \"http://localhost:3001/static/assets/upload?key=...&sig=...\",\n \"uploadMethod\": \"PUT\",\n \"uploadFields\": {},\n \"uploadExpiresAt\": \"2026-05-01T12:34:56Z\"\n}\n```\n\nResponse (POST-style, S3):\n```jsonc\n{\n \"id\": \"\",\n \"uploadUrl\": \"https://s3.example.com/bucket/\",\n \"uploadMethod\": \"POST\",\n \"uploadFields\": {\n \"key\": \"org_x/abc.jpg\",\n \"Content-Type\": \"image/jpeg\",\n \"policy\": \"\",\n \"x-amz-algorithm\": \"AWS4-HMAC-SHA256\",\n \"x-amz-credential\": \"AKIA.../20260501/eu-west-1/s3/aws4_request\",\n \"x-amz-date\": \"20260501T123456Z\",\n \"x-amz-signature\": \"\"\n },\n \"uploadExpiresAt\": \"2026-05-01T12:34:56Z\"\n}\n```\n\n## Step 2 — send the binary\n\nIf `uploadMethod === \"PUT\"`:\n\n```bash\ncurl --upload-file ./spring-launch-hero.jpg \\\n -H \"Content-Type: image/jpeg\" \\\n \"\"\n```\n\nIf `uploadMethod === \"POST\"`:\n\n```bash\ncurl -X POST \"\" \\\n $(printf -- '-F %s=%s ' key \"$KEY\" Content-Type image/jpeg policy \"$POLICY\" x-amz-algorithm AWS4-HMAC-SHA256 ...) \\\n -F \"file=@./spring-launch-hero.jpg\"\n```\n\nThe `file` part must come **last** in the multipart body. Send every key in `uploadFields` as a form field; the embedded policy fixes the allowed byte size to exactly `sizeBytes`, so an oversized body is rejected at S3. Must complete before `uploadExpiresAt` (typically ~15 min).\n\n## Step 3 — complete\n\n```jsonc\n{ \"name\": \"cms_complete_asset_upload\", \"arguments\": { \"id\": \"\" } }\n```\n\nFlips `uploaded: true`. Until you call this, the asset is invisible to other tools and entries can't reference it. You can call complete **as long as the row exists** — the URL expires, but the row doesn't.\n\n## Step 4 — embed in an entry\n\nThere are two ways to embed an asset, depending on whether it's a standalone field (a cover image, a gallery) or an image placed within prose. Both also work *inside a block* (`skill://cms/author-with-blocks`) — an asset used in a block, as a typed prop or an inline `asset://` token in block prose, is tracked the same way and is covered by the delete guard below.\n\n### As a typed field\n\nA field whose collection type is `asset` (or `array` of `asset`) stores the asset id. Read the entry, write the field, send the update:\n\n```jsonc\n{ \"name\": \"cms_get_entry\", \"arguments\": { \"id\": \"\" } }\n```\n\n```jsonc\n{\n \"name\": \"cms_update_entry\",\n \"arguments\": {\n \"id\": \"\",\n \"ifVersion\": 12,\n \"data\": { \"...all other fields...\": \"...\", \"heroImage\": \"\" }\n }\n}\n```\n\n### Inline in a markdown/rich_text body\n\nTo place an image within the prose of a `markdown` or `rich_text` field, embed an `asset://` reference — in markdown, as the URL of an image:\n\n```markdown\nIntro paragraph.\n\n![Spring launch hero](asset://)\n\nMore copy.\n```\n\nThe asset must already be uploaded (`uploaded: true`) — an inline reference to an unknown or unconfirmed asset is rejected when you create/update the entry. On read, the delivery API and `cms_get_entry`/`cms_search_entries` rewrite each `asset://` to the asset's `publicUrl` and attach an `_assets` map keyed by asset id so you can also read `altText`, `mime`, and `sizeBytes`.\n\n(Use `skill://cms/publish-entry` for the full update + publish dance.)\n\n## Auditing unused assets\n\n```jsonc\n{ \"name\": \"cms_list_assets\", \"arguments\": { \"limit\": 200 } }\n```\n\nTo see which entries use a given asset — as a typed field or inline in a body — call:\n\n```jsonc\n{ \"name\": \"cms_list_asset_usage\", \"arguments\": { \"assetId\": \"\" } }\n```\n\nIt returns one row per reference with `fromEntryId`, `fieldName`, and `kind` (`field` or `inline`). An empty result means the asset is safe to delete.\n\n## What NOT to do\n\n- **Don't call `cms_complete_asset_upload` before the binary PUT succeeds.** The asset will be marked `uploaded: true` with no actual file — entries referencing it will render broken.\n- **Don't lose the `id`.** Without it you can't complete the upload, and the half-uploaded row sits as an orphan (no automatic GC).\n- **Don't reuse one presigned URL for multiple files.** Each `cms_request_asset_upload` mints a new URL bound to the size and mime you declared.\n- **Don't expect to delete an asset that's still in use.** `cms_delete_asset` fails with a conflict while any entry references the asset (field or inline). Call `cms_list_asset_usage` first, then remove the references from those entries before deleting.\n\n## Related\n\n- `skill://cms/publish-entry` — the update + publish dance for the entry that embeds the asset.\n- `skill://cms/migrate-content` — when you're moving assets along with entries.\n" }, { "uri": "skill://commerce/answer-product-questions", @@ -123,7 +123,7 @@ "title": "Commerce: Check order status", "description": "Answer \"where is my order?\" — list a customer's recent orders, fetch one order's line items and shipment tracking, and know when to hand over to a human.", "mimeType": "text/markdown", - "content": "# Check order status\n\nOrder-status questions (\"where is my order?\", \"has it shipped?\", \"what did I order?\") are answered from the org's connected store. All order tools are read-only; nothing here can change, cancel, or refund an order.\n\n## Self-service (customer's own agent)\n\n- `commerce_get_my_orders` — the customer's recent orders, newest first. No email parameter exists: the lookup is fixed to the calling end-user's own identity.\n- `commerce_get_my_order` — one order with line items and shipment tracking. Pass `orderRef` from a listing, or `orderNumber` when the customer quotes the number from their confirmation email (leading `#` is fine).\n\nTypical flow for \"where is my order?\": get the most recent orders, and if one is obviously current, fetch its detail for tracking numbers. If the customer names an order number, go straight to `commerce_get_my_order`.\n\nIf the session has no email identity, the tools return an error instead of results — tell the customer you can't access order history in this session and offer a human handover (`conv_request_human`) rather than asking them to type an email; a typed email cannot be used for lookup.\n\n## Admin (support agent working a conversation)\n\n- `commerce_lookup_orders` / `commerce_lookup_order` — same data, but take an `email`. Use the verified email of the contact whose conversation you're handling; don't look up unrelated addresses on a customer's behalf.\n\n## Reading the results\n\n- `status` is vendor-native (Shopify: `open`/`closed`/`cancelled` plus `financialStatus`/`fulfillmentStatus`; Magento: its order status like `processing`, `complete`).\n- `shipments[].trackingNumbers` and `trackingUrls` are what the customer usually wants — quote the tracking number and link when present. An order without shipments simply hasn't shipped yet.\n- A not-found for an order number the customer insists exists usually means the order was placed under a different email; that's a case for human handover, not repeated lookups.\n\n## When to hand over\n\nRefunds, cancellations, address changes, or disputes can't be done here. Log the request (`crm_log_my_activity` on self-service) and escalate with `conv_request_human` so a human can act in the store's own admin.\n" + "content": "# Check order status\n\nOrder-status questions (\"where is my order?\", \"has it shipped?\", \"what did I order?\") are answered from the org's connected store. All order tools are read-only; nothing here can change, cancel, or refund an order.\n\n## Self-service (customer's own agent)\n\n- `commerce_list_my_orders` — the customer's recent orders, newest first. No email parameter exists: the lookup is fixed to the calling end-user's own identity.\n- `commerce_get_my_order` — one order with line items and shipment tracking. Pass `orderRef` from a listing, or `orderNumber` when the customer quotes the number from their confirmation email (leading `#` is fine).\n\nTypical flow for \"where is my order?\": get the most recent orders, and if one is obviously current, fetch its detail for tracking numbers. If the customer names an order number, go straight to `commerce_get_my_order`.\n\nIf the session has no email identity, the tools return an error instead of results — tell the customer you can't access order history in this session and offer a human handover (`conv_request_human`) rather than asking them to type an email; a typed email cannot be used for lookup.\n\n## Admin (support agent working a conversation)\n\n- `commerce_list_customer_orders` / `commerce_lookup_order` — same data, but take an `email`. Use the verified email of the contact whose conversation you're handling; don't look up unrelated addresses on a customer's behalf.\n\n## Reading the results\n\n- `status` is vendor-native (Shopify: `open`/`closed`/`cancelled` plus `financialStatus`/`fulfillmentStatus`; Magento: its order status like `processing`, `complete`).\n- `shipments[].trackingNumbers` and `trackingUrls` are what the customer usually wants — quote the tracking number and link when present. An order without shipments simply hasn't shipped yet.\n- A not-found for an order number the customer insists exists usually means the order was placed under a different email; that's a case for human handover, not repeated lookups.\n\n## When to hand over\n\nRefunds, cancellations, address changes, or disputes can't be done here. Log the request (`crm_log_my_activity` on self-service) and escalate with `conv_request_human` so a human can act in the store's own admin.\n" }, { "uri": "skill://connectors/connect-external-system", @@ -132,7 +132,7 @@ "title": "Connectors: Connect an external system", "description": "Connect Shopify, Magento 2, or Gastroplanner so agents can answer order and booking questions — create the vendor credential, register the connection, test it, and understand the identity model that keeps customers scoped to their own data.", "mimeType": "text/markdown", - "content": "# Connect an external system\n\nConnectors give agents read access to the org's third-party systems, grouped by domain: **commerce** (orders + product catalog — Shopify, Magento 2) and **bookings** (bookings — Gastroplanner). Admin agents use the lookup tools while handling a support conversation; customers' own agents get the self-service tools (`commerce_get_my_orders`, `bookings_get_my_bookings`), scoped server-side to their own records.\n\n`connectors_list_vendors` returns the supported systems and the exact config fields each one needs.\n\n## TL;DR\n\n1. Have the human create a read-only API credential in the external system (per-vendor steps below).\n2. `connectors_create_connection` with `vendor`, a `name`, and the vendor's **non-secret** config fields. The vendor determines the domain. The response includes a one-time **credential link**.\n3. Share the credential link — the human opens it and enters the secret directly in the dashboard. The link expires after 24 hours and works once; mint a fresh one with `connectors_request_credentials` if it lapses.\n4. `connectors_test_connection` — verifies the stored credential with a read-only probe.\n5. Mint delegated end-user tokens with `commerce:read` and/or `bookings:read` to enable customer self-service.\n\n**Never ask for a secret in the conversation and never put one in `config`** — the tool rejects secret fields. Secrets only enter through the credential link, are encrypted at rest, and are never returned by any tool. Munin only ever needs **read** access — never grant write scopes in the external system.\n\n## Shopify (commerce)\n\nCreate a custom app token in the Shopify admin:\n\n1. Shopify admin → **Settings → Apps and sales channels → Develop apps → Create an app**.\n2. Under **Configuration → Admin API integration**, grant exactly three scopes: `read_orders`, `read_customers`, and `read_products`.\n3. Install the app and copy the **Admin API access token** (`shpat_…`). Shopify shows it once.\n\n```json\n{\n \"vendor\": \"shopify\",\n \"name\": \"Main store\",\n \"config\": {\n \"shopDomain\": \"your-store.myshopify.com\"\n }\n}\n```\n\nThe credential link asks for the Admin API access token. `shopDomain` is the permanent `*.myshopify.com` domain, not your custom storefront domain, and is required at create time. `apiVersion` is optional (defaults to a current stable version).\n\nNote: apps with `read_orders` see the last 60 days of orders by default; request the `read_all_orders` scope in the Shopify app config if customers ask about older orders. A connection created before product lookups existed needs `read_products` added in the Shopify app config — no change in Munin, the stored token picks up the new scope.\n\n## Magento 2 / Adobe Commerce (commerce)\n\nCreate an integration token:\n\n1. Magento admin → **System → Extensions → Integrations → Add New Integration**.\n2. Under **API**, grant resource access to **Sales** (read), **Customers** (read), and **Catalog** (read) only. Include **CatalogInventory** if you want product lookups to report stock availability — without it, availability comes back as unknown rather than failing.\n3. Save, **Activate**, and copy the **Access Token**.\n\n```json\n{\n \"vendor\": \"magento\",\n \"name\": \"EU storefront\",\n \"config\": {\n \"baseUrl\": \"https://store.example.com\"\n }\n}\n```\n\nThe credential link asks for the access token. `baseUrl` must be https and publicly reachable (private/internal hosts are refused). Shipment tracking comes from the Sales → Shipments resource, so include it in the ACL.\n\n## Gastroplanner (bookings)\n\nGastroplanner's customer API (`https://api.gastroplanner.eu/docs/customer/`) uses a Bearer token plus an `X-RESTAURANT` header naming the restaurant every query runs against:\n\n1. Request an API token from Gastroplanner support for your account.\n2. Create the connection with the restaurant URI (the `X-RESTAURANT` value for your venue) — the credential link asks for the API token. `baseUrl` is only needed for a non-standard endpoint.\n\n```json\n{\n \"vendor\": \"gastroplanner\",\n \"name\": \"Restaurant bookings\",\n \"config\": {\n \"restaurantUri\": \"my-restaurant\"\n }\n}\n```\n\nRun `connectors_test_connection` after creating it — the probe lists the restaurants the token can access and fails with the available URIs if `restaurantUri` doesn't match one. Note: Gastroplanner bookings have no confirmation code; guests identify a booking by the `bookingRef` from a listing.\n\n## Multiple connections\n\nConnections are org-scoped and names must be unique. Within a domain: with one active connection, lookup tools use it automatically; with several, calls must pass `connectionId` (the error message lists the candidates). Connections in *different* domains never conflict — a Shopify store and a Gastroplanner account coexist without any `connectionId`. Deactivate with `connectors_update_connection { active: false }` to take one out of rotation without deleting the credential.\n\n`connectors_update_connection` handles renames, non-secret config changes, and activation — the stored secret is kept, and secret fields are rejected the same way as at create time. To rotate a credential, delete the connection and create it again, entering the new secret through the fresh credential link (or use the dashboard's Integrations page).\n\n## The identity model — read before enabling self-service\n\nSelf-service lookups trust **the email on the end-user record**, and end-user records are created by *your* backend when it mints delegated tokens (`POST /v1/tokens/delegated`). The chain is:\n\n your app authenticates the customer → mints a delegated token with their email\n → the self-service tool resolves that email server-side → vendor returns only that customer's records\n\nTwo rules follow:\n\n- **Only mint delegated tokens with emails your system has actually authenticated** (login session, verified email link). If you mint tokens from unauthenticated visitor input, you are asserting an identity you haven't checked, and that visitor's agent can read that email's order and booking history.\n- End-user records without an email can't look up anything — the tools refuse rather than guess.\n\nThe end-user can never choose which email to query: the self-service tools take no email parameter. Admin lookup tools (`commerce_lookup_orders`, `bookings_lookup_bookings`) can query any email — that surface is for your own support staff and is never exposed to delegated tokens.\n" + "content": "# Connect an external system\n\nConnectors give agents read access to the org's third-party systems, grouped by domain: **commerce** (orders + product catalog — Shopify, Magento 2) and **bookings** (bookings — Gastroplanner). Admin agents use the lookup tools while handling a support conversation; customers' own agents get the self-service tools (`commerce_list_my_orders`, `bookings_list_my_bookings`), scoped server-side to their own records.\n\n`connectors_list_vendors` returns the supported systems and the exact config fields each one needs.\n\n## TL;DR\n\n1. Have the human create a read-only API credential in the external system (per-vendor steps below).\n2. `connectors_create_connection` with `vendor`, a `name`, and the vendor's **non-secret** config fields. The vendor determines the domain. The response includes a one-time **credential link**.\n3. Share the credential link — the human opens it and enters the secret directly in the dashboard. The link expires after 24 hours and works once; mint a fresh one with `connectors_request_credentials` if it lapses.\n4. `connectors_test_connection` — verifies the stored credential with a read-only probe.\n5. Mint delegated end-user tokens with `commerce:read` and/or `bookings:read` to enable customer self-service.\n\n**Never ask for a secret in the conversation and never put one in `config`** — the tool rejects secret fields. Secrets only enter through the credential link, are encrypted at rest, and are never returned by any tool. Munin only ever needs **read** access — never grant write scopes in the external system.\n\n## Shopify (commerce)\n\nCreate a custom app token in the Shopify admin:\n\n1. Shopify admin → **Settings → Apps and sales channels → Develop apps → Create an app**.\n2. Under **Configuration → Admin API integration**, grant exactly three scopes: `read_orders`, `read_customers`, and `read_products`.\n3. Install the app and copy the **Admin API access token** (`shpat_…`). Shopify shows it once.\n\n```json\n{\n \"vendor\": \"shopify\",\n \"name\": \"Main store\",\n \"config\": {\n \"shopDomain\": \"your-store.myshopify.com\"\n }\n}\n```\n\nThe credential link asks for the Admin API access token. `shopDomain` is the permanent `*.myshopify.com` domain, not your custom storefront domain, and is required at create time. `apiVersion` is optional (defaults to a current stable version).\n\nNote: apps with `read_orders` see the last 60 days of orders by default; request the `read_all_orders` scope in the Shopify app config if customers ask about older orders. A connection created before product lookups existed needs `read_products` added in the Shopify app config — no change in Munin, the stored token picks up the new scope.\n\n## Magento 2 / Adobe Commerce (commerce)\n\nCreate an integration token:\n\n1. Magento admin → **System → Extensions → Integrations → Add New Integration**.\n2. Under **API**, grant resource access to **Sales** (read), **Customers** (read), and **Catalog** (read) only. Include **CatalogInventory** if you want product lookups to report stock availability — without it, availability comes back as unknown rather than failing.\n3. Save, **Activate**, and copy the **Access Token**.\n\n```json\n{\n \"vendor\": \"magento\",\n \"name\": \"EU storefront\",\n \"config\": {\n \"baseUrl\": \"https://store.example.com\"\n }\n}\n```\n\nThe credential link asks for the access token. `baseUrl` must be https and publicly reachable (private/internal hosts are refused). Shipment tracking comes from the Sales → Shipments resource, so include it in the ACL.\n\n## Gastroplanner (bookings)\n\nGastroplanner's customer API (`https://api.gastroplanner.eu/docs/customer/`) uses a Bearer token plus an `X-RESTAURANT` header naming the restaurant every query runs against:\n\n1. Request an API token from Gastroplanner support for your account.\n2. Create the connection with the restaurant URI (the `X-RESTAURANT` value for your venue) — the credential link asks for the API token. `baseUrl` is only needed for a non-standard endpoint.\n\n```json\n{\n \"vendor\": \"gastroplanner\",\n \"name\": \"Restaurant bookings\",\n \"config\": {\n \"restaurantUri\": \"my-restaurant\"\n }\n}\n```\n\nRun `connectors_test_connection` after creating it — the probe lists the restaurants the token can access and fails with the available URIs if `restaurantUri` doesn't match one. Note: Gastroplanner bookings have no confirmation code; guests identify a booking by the `bookingRef` from a listing.\n\n## Multiple connections\n\nConnections are org-scoped and names must be unique. Within a domain: with one active connection, lookup tools use it automatically; with several, calls must pass `connectionId` (the error message lists the candidates). Connections in *different* domains never conflict — a Shopify store and a Gastroplanner account coexist without any `connectionId`. Deactivate with `connectors_update_connection { active: false }` to take one out of rotation without deleting the credential.\n\n`connectors_update_connection` handles renames, non-secret config changes, and activation — the stored secret is kept, and secret fields are rejected the same way as at create time. To rotate a credential, delete the connection and create it again, entering the new secret through the fresh credential link (or use the dashboard's Integrations page).\n\n## The identity model — read before enabling self-service\n\nSelf-service lookups trust **the email on the end-user record**, and end-user records are created by *your* backend when it mints delegated tokens (`POST /v1/tokens/delegated`). The chain is:\n\n your app authenticates the customer → mints a delegated token with their email\n → the self-service tool resolves that email server-side → vendor returns only that customer's records\n\nTwo rules follow:\n\n- **Only mint delegated tokens with emails your system has actually authenticated** (login session, verified email link). If you mint tokens from unauthenticated visitor input, you are asserting an identity you haven't checked, and that visitor's agent can read that email's order and booking history.\n- End-user records without an email can't look up anything — the tools refuse rather than guess.\n\nThe end-user can never choose which email to query: the self-service tools take no email parameter. Admin lookup tools (`commerce_list_customer_orders`, `bookings_list_guest_bookings`) can query any email — that surface is for your own support staff and is never exposed to delegated tokens.\n" }, { "uri": "skill://conv/escalate-to-human", @@ -159,7 +159,7 @@ "title": "Conv: Set up a chat widget", "description": "Provision a per-channel widget API key, push transcripts via POST /v1/widget/messages, and wire the human-handoff webhook.", "mimeType": "text/markdown", - "content": "# Set up a chat widget\nLets an external AI agent running as a chat widget on a customer's website push transcripts into Munin's conversation module. Once the conversation is in Munin, a human in the dashboard can reply, and the customer's webhook receiver tells the external agent to step back.\n\n## 1. Create the channel and mint a widget key\n\nCall `conv_widget_create_channel`:\n\n```jsonc\n{\n \"name\": \"storefront-bot\",\n \"originAllowlist\": [\"https://customer.example\"]\n}\n```\n\nResponse includes `widgetKey: \"mn_widget_…\"` — shown once. Store it server-side.\n\n`originAllowlist` is required — the widget ingest endpoint rejects any request whose `Origin` header doesn't match one of the listed full origins (scheme + host + port, exact match). List every environment that should be allowed to ingest (`https://customer.example`, `https://staging.customer.example`, etc.).\n\nThe widget key is bound to this channel via `api_keys.channel_id`. Rotate with `conv_widget_rotate_key`; update origins with `conv_widget_update_channel`.\n\n## 2. Push transcripts from the agent\n\n`POST /v1/widget/messages` — server-to-server is the recommended integration so the key never reaches browser JS.\n\n```bash\ncurl -sS https://munin.example/v1/widget/messages \\\n -H \"Authorization: Bearer $MUNIN_WIDGET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"channelId\": \"cch_…\",\n \"sessionId\": \"vis_abc123\",\n \"visitor\": { \"name\": \"Vita\", \"email\": \"vita@example.com\" },\n \"url\": \"https://customer.example/checkout\",\n \"messages\": [\n { \"role\": \"end_user\", \"body\": \"Where is my order?\", \"providerMessageId\": \"evt_1\" },\n { \"role\": \"agent\", \"body\": \"Let me check…\", \"providerMessageId\": \"evt_2\" }\n ]\n }'\n```\n\nResponse: `{ conversationId, displayId, contactId, inserted, skipped }`.\n\n### Conversation upsert\n\nConversations are keyed by `(orgId, channelId, metadata.sessionId)`. Sending the same `sessionId` again appends to the existing conversation; a new `sessionId` opens a new one.\n\n### Idempotency\n\nIf you set `providerMessageId` on a message, replays of the same identifier are silently skipped (counted as `skipped`). Without `providerMessageId`, every POST inserts new rows — that's by design (the agent opts into idempotency by including the field).\n\n### Visitor enrichment\n\n`visitor.email` enables CRM linkage: the contact is matched on (org, email). If you don't have an email, the contact is matched on `metadata.sessionId` so re-pushes update the same row. Once the visitor identifies themselves, send the email — the existing contact gets enriched rather than duplicated.\n\n## 3. Receive replies from a human / Munin agent\n\nWhen a Munin user replies in the conversation UI, `conversation.message.sent` fires on every webhook subscribed to that event. Subscribe your endpoint and:\n\n1. Fetch the message via the standard `conv_get_conversation` tool.\n2. Render it in the customer-side widget UI.\n3. Optionally signal your AI to step back so the human owns the thread.\n\nSame webhook surface used elsewhere in Munin — no widget-specific events.\n\n## 4. Verified identity (optional)\n\nBy default widget visitors are anonymous — contacts are keyed on `metadata.sessionId` (or `visitor.email` if sent). To tie a widget session to a *known* user (and gate anonymous access), attach a signed identity: a `verifiedExternalId` + `userHash` pair the ingest endpoint verifies against the channel's identity-verification secret.\n\n`conv_widget_create_channel` returns `identityVerificationSecret` once (alongside `widgetKey`). Treat it like an OAuth client secret — store it server-side, never embed it in browser JS. Rotate with `conv_widget_rotate_identity_secret` (previously-issued hashes stop verifying immediately). This is a **separate secret from the analytics tracker's** — sign widget hashes with the widget channel's secret, never the tracker secret.\n\nCompute the hash server-side:\n\n```ts\nimport { createHmac } from 'node:crypto';\n\nfunction userHash(externalId: string, secret: string): string {\n return createHmac('sha256', secret).update(externalId).digest('hex');\n}\n```\n\nThe widget hash covers `externalId` **only** — no visitor binding. That's a deliberate contrast with the analytics tracker, whose identify hash binds the visitor (`HMAC(\\`${externalId}:${visitorId}\\`)`, see `skill://analytics/identify-visitors`) and therefore needs a per-session browser round-trip. Because the widget hash is static per user, you can **server-render it** into the embed with no round-trip:\n\n```html\n\n```\n\n`data-external-id` and `data-user-hash` are all-or-nothing: sending one without the other is rejected (`identity_partial`). Render them only for signed-in users; omit both for anonymous visitors. (On browser-direct calls, the same values are passed as the `verifiedExternalId` + `userHash` params.)\n\nSet `requireVerifiedIdentity: true` on the channel (`conv_widget_create_channel` / `conv_widget_update_channel`) to reject unverified sessions outright; the default (`false`) allows anonymous ingest alongside verified ones.\n\nBecause the widget and the analytics tracker share the same `localStorage` visitor id (`mn.vid`), identifying a visitor to the widget also stitches their prior anonymous analytics history — no separate `window.mn.identify` call needed for that visitor.\n\n### Sharing a session across subdomains\n\nThe visitor id and session id live in `localStorage` (with a cookie fallback), both scoped to the exact host by default. A conversation started on `www.example.com` therefore does **not** carry over to `app.example.com`. To share one thread across sibling subdomains — e.g. an anonymous chat on the marketing site that continues (and gets claimed) once the visitor signs in on the app — set `data-munin-cookie-domain` to a shared parent domain on every embed:\n\n```html\n\n```\n\nThe session + visitor cookies are then written with that `Domain`, so both subdomains read the same ids and the anonymous thread is claimed on identify. The value must be a suffix of the current host (`.example.com` on `app.example.com`); anything else is ignored client-side to avoid the browser silently dropping the cookie.\n\n## 5. Browser-direct integration (less secure)\n\nIf you must call the endpoint from browser JS, the channel's `originAllowlist` reflects allowed `Origin` headers and the endpoint sets the matching `Access-Control-Allow-Origin`. Anyone on a listed origin can use the key; rotation is one tool call. Server-side is strongly preferred.\n\n## 6. Operations\n\n| Task | How |\n|---|---|\n| Disable the channel | Set `conv_channels.active=false`. Existing keys still auth but ingest returns 403. |\n| Rotate the widget key | `conv_widget_rotate_key`. Old key revoked; existing inflight requests with it 401. |\n| Rotate the identity secret | `conv_widget_rotate_identity_secret`. Previously-issued `data-user-hash` values stop verifying; re-render signed-in pages with freshly-computed hashes. |\n| Tighten `originAllowlist` | `conv_widget_update_channel`. |\n| Inspect a conversation | Standard `conv_*` tools. The `metadata.sessionId`, `metadata.providerMessageId`, and `metadata.url` fields tell you the visitor's session. |\n" + "content": "# Set up a chat widget\nLets an external AI agent running as a chat widget on a customer's website push transcripts into Munin's conversation module. Once the conversation is in Munin, a human in the dashboard can reply, and the customer's webhook receiver tells the external agent to step back.\n\n## 1. Create the channel and mint a widget key\n\nCall `conv_create_widget_channel`:\n\n```jsonc\n{\n \"name\": \"storefront-bot\",\n \"originAllowlist\": [\"https://customer.example\"]\n}\n```\n\nResponse includes `widgetKey: \"mn_widget_…\"` — shown once. Store it server-side.\n\n`originAllowlist` is required — the widget ingest endpoint rejects any request whose `Origin` header doesn't match one of the listed full origins (scheme + host + port, exact match). List every environment that should be allowed to ingest (`https://customer.example`, `https://staging.customer.example`, etc.).\n\nThe widget key is bound to this channel via `api_keys.channel_id`. Rotate with `conv_rotate_widget_key`; update origins with `conv_update_widget_channel`.\n\n## 2. Push transcripts from the agent\n\n`POST /v1/widget/messages` — server-to-server is the recommended integration so the key never reaches browser JS.\n\n```bash\ncurl -sS https://munin.example/v1/widget/messages \\\n -H \"Authorization: Bearer $MUNIN_WIDGET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"channelId\": \"cch_…\",\n \"sessionId\": \"vis_abc123\",\n \"visitor\": { \"name\": \"Vita\", \"email\": \"vita@example.com\" },\n \"url\": \"https://customer.example/checkout\",\n \"messages\": [\n { \"role\": \"end_user\", \"body\": \"Where is my order?\", \"providerMessageId\": \"evt_1\" },\n { \"role\": \"agent\", \"body\": \"Let me check…\", \"providerMessageId\": \"evt_2\" }\n ]\n }'\n```\n\nResponse: `{ conversationId, displayId, contactId, inserted, skipped }`.\n\n### Conversation upsert\n\nConversations are keyed by `(orgId, channelId, metadata.sessionId)`. Sending the same `sessionId` again appends to the existing conversation; a new `sessionId` opens a new one.\n\n### Idempotency\n\nIf you set `providerMessageId` on a message, replays of the same identifier are silently skipped (counted as `skipped`). Without `providerMessageId`, every POST inserts new rows — that's by design (the agent opts into idempotency by including the field).\n\n### Visitor enrichment\n\n`visitor.email` enables CRM linkage: the contact is matched on (org, email). If you don't have an email, the contact is matched on `metadata.sessionId` so re-pushes update the same row. Once the visitor identifies themselves, send the email — the existing contact gets enriched rather than duplicated.\n\n## 3. Receive replies from a human / Munin agent\n\nWhen a Munin user replies in the conversation UI, `conversation.message.sent` fires on every webhook subscribed to that event. Subscribe your endpoint and:\n\n1. Fetch the message via the standard `conv_get_conversation` tool.\n2. Render it in the customer-side widget UI.\n3. Optionally signal your AI to step back so the human owns the thread.\n\nSame webhook surface used elsewhere in Munin — no widget-specific events.\n\n## 4. Verified identity (optional)\n\nBy default widget visitors are anonymous — contacts are keyed on `metadata.sessionId` (or `visitor.email` if sent). To tie a widget session to a *known* user (and gate anonymous access), attach a signed identity: a `verifiedExternalId` + `userHash` pair the ingest endpoint verifies against the channel's identity-verification secret.\n\n`conv_create_widget_channel` returns `identityVerificationSecret` once (alongside `widgetKey`). Treat it like an OAuth client secret — store it server-side, never embed it in browser JS. Rotate with `conv_rotate_widget_identity_secret` (previously-issued hashes stop verifying immediately). This is a **separate secret from the analytics tracker's** — sign widget hashes with the widget channel's secret, never the tracker secret.\n\nCompute the hash server-side:\n\n```ts\nimport { createHmac } from 'node:crypto';\n\nfunction userHash(externalId: string, secret: string): string {\n return createHmac('sha256', secret).update(externalId).digest('hex');\n}\n```\n\nThe widget hash covers `externalId` **only** — no visitor binding. That's a deliberate contrast with the analytics tracker, whose identify hash binds the visitor (`HMAC(\\`${externalId}:${visitorId}\\`)`, see `skill://analytics/identify-visitors`) and therefore needs a per-session browser round-trip. Because the widget hash is static per user, you can **server-render it** into the embed with no round-trip:\n\n```html\n\n```\n\n`data-external-id` and `data-user-hash` are all-or-nothing: sending one without the other is rejected (`identity_partial`). Render them only for signed-in users; omit both for anonymous visitors. (On browser-direct calls, the same values are passed as the `verifiedExternalId` + `userHash` params.)\n\nSet `requireVerifiedIdentity: true` on the channel (`conv_create_widget_channel` / `conv_update_widget_channel`) to reject unverified sessions outright; the default (`false`) allows anonymous ingest alongside verified ones.\n\nBecause the widget and the analytics tracker share the same `localStorage` visitor id (`mn.vid`), identifying a visitor to the widget also stitches their prior anonymous analytics history — no separate `window.mn.identify` call needed for that visitor.\n\n### Sharing a session across subdomains\n\nThe visitor id and session id live in `localStorage` (with a cookie fallback), both scoped to the exact host by default. A conversation started on `www.example.com` therefore does **not** carry over to `app.example.com`. To share one thread across sibling subdomains — e.g. an anonymous chat on the marketing site that continues (and gets claimed) once the visitor signs in on the app — set `data-munin-cookie-domain` to a shared parent domain on every embed:\n\n```html\n\n```\n\nThe session + visitor cookies are then written with that `Domain`, so both subdomains read the same ids and the anonymous thread is claimed on identify. The value must be a suffix of the current host (`.example.com` on `app.example.com`); anything else is ignored client-side to avoid the browser silently dropping the cookie.\n\n## 5. Browser-direct integration (less secure)\n\nIf you must call the endpoint from browser JS, the channel's `originAllowlist` reflects allowed `Origin` headers and the endpoint sets the matching `Access-Control-Allow-Origin`. Anyone on a listed origin can use the key; rotation is one tool call. Server-side is strongly preferred.\n\n## 6. Operations\n\n| Task | How |\n|---|---|\n| Disable the channel | Set `conv_channels.active=false`. Existing keys still auth but ingest returns 403. |\n| Rotate the widget key | `conv_rotate_widget_key`. Old key revoked; existing inflight requests with it 401. |\n| Rotate the identity secret | `conv_rotate_widget_identity_secret`. Previously-issued `data-user-hash` values stop verifying; re-render signed-in pages with freshly-computed hashes. |\n| Tighten `originAllowlist` | `conv_update_widget_channel`. |\n| Inspect a conversation | Standard `conv_*` tools. The `metadata.sessionId`, `metadata.providerMessageId`, and `metadata.url` fields tell you the visitor's session. |\n" }, { "uri": "skill://conv/setup-email-and-widget-channels", @@ -168,7 +168,7 @@ "title": "Conv: Set up email and chat-widget channels together", "description": "Stand up email + widget conversation channels for a new tenant in one pass — configure, test, hand off.", "mimeType": "text/markdown", - "content": "# Set up email and chat-widget channels together\nA common onboarding shape for a new customer: they want both an email channel for ticket-style threads and a chat-widget channel for their website. This skill orchestrates both — it doesn't reproduce the per-channel detail, it points to the per-channel skills for the specifics.\n\n> **Read alongside:**\n> - `skill://conv/setup-email-channel` — full SMTP/IMAP config detail.\n> - `skill://conv/setup-chat-widget` — widget key minting + push transcript flow.\n> - `skill://conv/escalate-to-human` — once a human is involved, how the external bot yields.\n\n## TL;DR\n\n1. `conv_list_channels` — see whether any channel exists for this org.\n2. Email channel: `conv_setup_email_channel` → `conv_test_email_channel` to verify.\n3. Widget channel: `conv_widget_create_channel` → store the `widgetKey` server-side.\n4. `conv_list_channels` again to confirm both are `active: true`.\n5. (Optional) `conv_create_topic` per topic so the inbox has Billing / Support / Bug topics ready.\n\n## Step 1 — check current state\n\n```jsonc\n{ \"name\": \"conv_list_channels\", \"arguments\": {} }\n```\n\nLists existing channels and their `active` flag. If the org already has an email or widget channel, skip the corresponding step.\n\n## Step 2 — email channel\n\nPer `skill://conv/setup-email-channel`, gather:\n- `fromAddress` — the real mailbox the customer controls.\n- Outbound mode: `smtp` (host, port, secure, username — the password comes through the credential link, never the conversation) or `mailer` (Munin's configured Resend).\n- Inbound mode: `imap` (host, port, secure, username, mailbox — password via the credential link), or skip if the customer will forward to a `MUNIN_EMAIL_REPLY_DOMAIN` address.\n\nThen:\n\n```jsonc\n{\n \"name\": \"conv_setup_email_channel\",\n \"arguments\": {\n \"name\": \"Acme Support\",\n \"config\": {\n \"addressing\": { \"fromAddress\": \"support@acme.com\", \"fromName\": \"Acme Support\" },\n \"outbound\": { \"provider\": \"smtp\", \"host\": \"smtp.acme.com\", \"port\": 587, \"secure\": false, \"username\": \"support@acme.com\" },\n \"inbound\": { \"host\": \"imap.acme.com\", \"port\": 993, \"secure\": true, \"username\": \"support@acme.com\", \"mailbox\": \"INBOX\" }\n }\n }\n}\n```\n\nThe response includes a one-time `credentialLink` — share it so a human can enter the SMTP/IMAP passwords in the dashboard. The channel activates when they save (the passwords are verified against the servers).\n\nThe response includes the channel id. Passwords come back as `••••` — they're encrypted via pgcrypto and never re-emitted.\n\nVerify:\n\n```jsonc\n{ \"name\": \"conv_test_email_channel\", \"arguments\": { \"channelId\": \"\" } }\n```\n\nReturns `{ smtp: 'ok'|'error: …', imap: 'ok'|'error: …'|'not configured' }`. **Don't proceed if SMTP fails** — outbound mail will silently dead-letter. IMAP failures are softer (you can fall back to forwarding).\n\n## Step 3 — widget channel\n\nPer `skill://conv/setup-chat-widget`:\n\n```jsonc\n{\n \"name\": \"conv_widget_create_channel\",\n \"arguments\": {\n \"name\": \"acme-storefront-bot\",\n \"originAllowlist\": [\"https://www.acme.com\", \"https://shop.acme.com\"]\n }\n}\n```\n\nResponse includes `widgetKey: \"mn_widget_...\"` — **shown once**. Hand it to the customer's developer to store server-side. Rotate later via `conv_widget_rotate_key` if it leaks.\n\n`originAllowlist` is enforced on every push from the widget endpoint; setting it correctly here saves a CORS bug report later.\n\nIf the customer wants escalation webhooks (their bot should yield when a human in Munin replies), point them at `skill://conv/escalate-to-human` — handoff uses the standard `conversation.message.sent` webhook, not a widget-specific one.\n\n## Step 4 — confirm\n\n```jsonc\n{ \"name\": \"conv_list_channels\", \"arguments\": {} }\n```\n\nBoth channels should appear with `active: true`. If either is `active: false`, the test/setup didn't complete — re-run that channel's flow.\n\n## Step 5 — seed topics (optional)\n\nTopics are conversation labels that route inbound messages to the right humans. Create the starter set with `conv_create_topic`, one call per topic:\n\n```jsonc\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Billing\" } }\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Bug report\" } }\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Feature request\" } }\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Account access\" } }\n```\n\n## What NOT to do\n\n- **Don't try to assign channels to a team at create time.** Channels are org-scoped; routing happens per-conversation via `conv_assign_conversation`. There's no \"channel owner\" field.\n- **Don't paste the widget key into the customer's repo or a Slack message.** Treat it like a password — it's shown once because the server only stores its hash.\n- **Don't skip `conv_test_email_channel`.** A bad SMTP password silently fails on the first delivery attempt and looks like a customer-side problem when really it's a config error.\n- **Don't enable the widget without an `originAllowlist`.** An empty list means anyone can POST to the widget endpoint with the key — fine for staging, embarrassing in production.\n\n## Related\n\n- `skill://conv/setup-email-channel` — detailed SMTP/IMAP config.\n- `skill://conv/setup-chat-widget` — push transcript flow + key minting.\n- `skill://conv/escalate-to-human` — webhook-based handoff.\n- `skill://playbooks/support-desk-launch` — full support desk stand-up across conv + crm + kb.\n" + "content": "# Set up email and chat-widget channels together\nA common onboarding shape for a new customer: they want both an email channel for ticket-style threads and a chat-widget channel for their website. This skill orchestrates both — it doesn't reproduce the per-channel detail, it points to the per-channel skills for the specifics.\n\n> **Read alongside:**\n> - `skill://conv/setup-email-channel` — full SMTP/IMAP config detail.\n> - `skill://conv/setup-chat-widget` — widget key minting + push transcript flow.\n> - `skill://conv/escalate-to-human` — once a human is involved, how the external bot yields.\n\n## TL;DR\n\n1. `conv_list_channels` — see whether any channel exists for this org.\n2. Email channel: `conv_configure_email_channel` → `conv_test_email_channel` to verify.\n3. Widget channel: `conv_create_widget_channel` → store the `widgetKey` server-side.\n4. `conv_list_channels` again to confirm both are `active: true`.\n5. (Optional) `conv_create_topic` per topic so the inbox has Billing / Support / Bug topics ready.\n\n## Step 1 — check current state\n\n```jsonc\n{ \"name\": \"conv_list_channels\", \"arguments\": {} }\n```\n\nLists existing channels and their `active` flag. If the org already has an email or widget channel, skip the corresponding step.\n\n## Step 2 — email channel\n\nPer `skill://conv/setup-email-channel`, gather:\n- `fromAddress` — the real mailbox the customer controls.\n- Outbound mode: `smtp` (host, port, secure, username — the password comes through the credential link, never the conversation) or `mailer` (Munin's configured Resend).\n- Inbound mode: `imap` (host, port, secure, username, mailbox — password via the credential link), or skip if the customer will forward to a `MUNIN_EMAIL_REPLY_DOMAIN` address.\n\nThen:\n\n```jsonc\n{\n \"name\": \"conv_configure_email_channel\",\n \"arguments\": {\n \"name\": \"Acme Support\",\n \"config\": {\n \"addressing\": { \"fromAddress\": \"support@acme.com\", \"fromName\": \"Acme Support\" },\n \"outbound\": { \"provider\": \"smtp\", \"host\": \"smtp.acme.com\", \"port\": 587, \"secure\": false, \"username\": \"support@acme.com\" },\n \"inbound\": { \"host\": \"imap.acme.com\", \"port\": 993, \"secure\": true, \"username\": \"support@acme.com\", \"mailbox\": \"INBOX\" }\n }\n }\n}\n```\n\nThe response includes a one-time `credentialLink` — share it so a human can enter the SMTP/IMAP passwords in the dashboard. The channel activates when they save (the passwords are verified against the servers).\n\nThe response includes the channel id. Passwords come back as `••••` — they're encrypted via pgcrypto and never re-emitted.\n\nVerify:\n\n```jsonc\n{ \"name\": \"conv_test_email_channel\", \"arguments\": { \"channelId\": \"\" } }\n```\n\nReturns `{ smtp: 'ok'|'error: …', imap: 'ok'|'error: …'|'not configured' }`. **Don't proceed if SMTP fails** — outbound mail will silently dead-letter. IMAP failures are softer (you can fall back to forwarding).\n\n## Step 3 — widget channel\n\nPer `skill://conv/setup-chat-widget`:\n\n```jsonc\n{\n \"name\": \"conv_create_widget_channel\",\n \"arguments\": {\n \"name\": \"acme-storefront-bot\",\n \"originAllowlist\": [\"https://www.acme.com\", \"https://shop.acme.com\"]\n }\n}\n```\n\nResponse includes `widgetKey: \"mn_widget_...\"` — **shown once**. Hand it to the customer's developer to store server-side. Rotate later via `conv_rotate_widget_key` if it leaks.\n\n`originAllowlist` is enforced on every push from the widget endpoint; setting it correctly here saves a CORS bug report later.\n\nIf the customer wants escalation webhooks (their bot should yield when a human in Munin replies), point them at `skill://conv/escalate-to-human` — handoff uses the standard `conversation.message.sent` webhook, not a widget-specific one.\n\n## Step 4 — confirm\n\n```jsonc\n{ \"name\": \"conv_list_channels\", \"arguments\": {} }\n```\n\nBoth channels should appear with `active: true`. If either is `active: false`, the test/setup didn't complete — re-run that channel's flow.\n\n## Step 5 — seed topics (optional)\n\nTopics are conversation labels that route inbound messages to the right humans. Create the starter set with `conv_create_topic`, one call per topic:\n\n```jsonc\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Billing\" } }\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Bug report\" } }\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Feature request\" } }\n{ \"name\": \"conv_create_topic\", \"arguments\": { \"name\": \"Account access\" } }\n```\n\n## What NOT to do\n\n- **Don't try to assign channels to a team at create time.** Channels are org-scoped; routing happens per-conversation via `conv_assign_conversation`. There's no \"channel owner\" field.\n- **Don't paste the widget key into the customer's repo or a Slack message.** Treat it like a password — it's shown once because the server only stores its hash.\n- **Don't skip `conv_test_email_channel`.** A bad SMTP password silently fails on the first delivery attempt and looks like a customer-side problem when really it's a config error.\n- **Don't enable the widget without an `originAllowlist`.** An empty list means anyone can POST to the widget endpoint with the key — fine for staging, embarrassing in production.\n\n## Related\n\n- `skill://conv/setup-email-channel` — detailed SMTP/IMAP config.\n- `skill://conv/setup-chat-widget` — push transcript flow + key minting.\n- `skill://conv/escalate-to-human` — webhook-based handoff.\n- `skill://playbooks/support-desk-launch` — full support desk stand-up across conv + crm + kb.\n" }, { "uri": "skill://conv/setup-email-channel", @@ -177,7 +177,7 @@ "title": "Conv: Set up an email channel", "description": "Configure SMTP outbound and optional IMAP inbound for an email channel, then verify the credentials.", "mimeType": "text/markdown", - "content": "# Set up an email channel\nUse this when a customer wants Munin to send and receive email under one of their addresses (e.g. `support@acme.com`).\n\n## TL;DR\n\n1. Decide outbound: their own SMTP server, or send through Munin's configured Mailer (Resend).\n2. Decide inbound: poll IMAP, or rely on the customer forwarding to a `MUNIN_EMAIL_REPLY_DOMAIN` address.\n3. Call `conv_setup_email_channel` with the non-secret config — **never ask for or pass passwords**. The response includes a one-time **credential link**.\n4. Share the credential link — the human enters the SMTP/IMAP passwords in the dashboard; the channel activates and the passwords are verified against the servers on save. The link works once and expires after 24 hours; mint a fresh one with `conv_request_channel_credentials`.\n5. Confirm the channel appears in `conv_list_channels` with `active: true`; `conv_test_email_channel` re-verifies credentials any time.\n\nA `mailer`-outbound channel with no IMAP inbound needs no secrets at all — it is active immediately, no link involved.\n\n## Step 1 — gather the config\n\nRequired from the operator:\n\n- **Addressing**: `fromAddress` (must be a real mailbox they control), optional `fromName` (e.g. \"Acme Support\").\n- **Outbound mode**:\n - `smtp` — host, port, secure (TLS yes/no), username. Most providers: port 587 with `secure: false` (STARTTLS) or 465 with `secure: true`. The password is entered through the credential link, never in the conversation.\n - `mailer` — no extra config; uses the Munin instance's configured Mailer. Best for self-host without an SMTP relay.\n- **Inbound (optional)**: IMAP host, port, secure, username, mailbox name (defaults to `INBOX`). The password comes through the credential link.\n\nPasswords are stored encrypted via pgcrypto and only enter Munin through the credential link. Re-calling `conv_setup_email_channel` for updates keeps the stored passwords — non-secret fields can be changed freely.\n\n## Step 2 — create the channel\n\nCall `conv_setup_email_channel` (admin):\n\n```jsonc\n{\n \"name\": \"Acme Support\",\n \"config\": {\n \"addressing\": {\n \"fromAddress\": \"support@acme.com\",\n \"fromName\": \"Acme Support\",\n \"replyToTemplate\": \"support+conv-{conversationId}@acme.com\"\n },\n \"outbound\": {\n \"provider\": \"smtp\",\n \"host\": \"smtp.acme.com\",\n \"port\": 587,\n \"secure\": false,\n \"username\": \"support@acme.com\"\n },\n \"inbound\": {\n \"provider\": \"imap\",\n \"host\": \"imap.acme.com\",\n \"port\": 993,\n \"secure\": true,\n \"username\": \"support@acme.com\",\n \"mailbox\": \"INBOX\"\n }\n }\n}\n```\n\nReturns the channel ID, type `'email'`, the redacted DTO (passwords show as `••••`), and — when SMTP or IMAP is configured — a `credentialLink` with a one-time URL. The channel stays `active: false` until the human opens the link and saves the passwords; saving verifies them against the SMTP/IMAP servers and activates the channel.\n\nTo **update** an existing channel, pass `channelId` and only the fields you want to change. Stored passwords are preserved; to rotate a password, mint a link with `conv_request_channel_credentials`.\n\n## Step 3 — verify\n\nCall `conv_test_email_channel` with `{ channelId }`. It performs:\n\n- An SMTP `verify()` (no mail sent).\n- An IMAP `connect()` then `logout()` (no fetch).\n\nReturns `{ smtp: 'ok' | 'error: ', imap: 'ok' | 'error: ' | 'not configured' }`.\n\nIf `smtp` reports auth failure, the most common causes are app-password-required (Gmail, iCloud), the wrong port for the cipher (587 STARTTLS vs 465 implicit-TLS), or a region-specific endpoint (Microsoft 365 enforces `smtp.office365.com`).\n\n## Step 4 — confirm registration\n\nCall `conv_list_channels`. Look for the new row with `type: 'email'`, `active: true`, and the addressing block.\n\n## What happens next\n\n- **Outbound**: when an admin uses `conv_send_message` on a conversation tied to this channel, the message is enqueued in `conv_message_deliveries`. The `OutboundDeliveryWorker` drains that queue and the email adapter sends via SMTP (or the Mailer).\n- **Inbound** (if IMAP is configured): the `InboundPollWorker` ticks every 60s, fetches new UIDs, threads each message into an existing conversation (via `In-Reply-To` + `References` headers) or opens a new one. End-user senders are auto-created as `conv_contacts`.\n\n## Troubleshooting\n\n- **No outbound delivery** — check `conv_message_deliveries` rows for the channel. `status='dead'` means 5 attempts failed; the `error` column has the SMTP response. `conv_test_email_channel` is the fastest way to check creds.\n- **Inbound stuck** — `conv_inbound_state.cursor.lastUid` shows the high-water mark; `last_polled_at` shows the most recent tick; `last_error` carries any IMAP error.\n- **Email lands in spam at the recipient** — confirm SPF / DKIM / DMARC for the `fromAddress` domain. Munin doesn't manage DNS.\n" + "content": "# Set up an email channel\nUse this when a customer wants Munin to send and receive email under one of their addresses (e.g. `support@acme.com`).\n\n## TL;DR\n\n1. Decide outbound: their own SMTP server, or send through Munin's configured Mailer (Resend).\n2. Decide inbound: poll IMAP, or rely on the customer forwarding to a `MUNIN_EMAIL_REPLY_DOMAIN` address.\n3. Call `conv_configure_email_channel` with the non-secret config — **never ask for or pass passwords**. The response includes a one-time **credential link**.\n4. Share the credential link — the human enters the SMTP/IMAP passwords in the dashboard; the channel activates and the passwords are verified against the servers on save. The link works once and expires after 24 hours; mint a fresh one with `conv_request_channel_credentials`.\n5. Confirm the channel appears in `conv_list_channels` with `active: true`; `conv_test_email_channel` re-verifies credentials any time.\n\nA `mailer`-outbound channel with no IMAP inbound needs no secrets at all — it is active immediately, no link involved.\n\n## Step 1 — gather the config\n\nRequired from the operator:\n\n- **Addressing**: `fromAddress` (must be a real mailbox they control), optional `fromName` (e.g. \"Acme Support\").\n- **Outbound mode**:\n - `smtp` — host, port, secure (TLS yes/no), username. Most providers: port 587 with `secure: false` (STARTTLS) or 465 with `secure: true`. The password is entered through the credential link, never in the conversation.\n - `mailer` — no extra config; uses the Munin instance's configured Mailer. Best for self-host without an SMTP relay.\n- **Inbound (optional)**: IMAP host, port, secure, username, mailbox name (defaults to `INBOX`). The password comes through the credential link.\n\nPasswords are stored encrypted via pgcrypto and only enter Munin through the credential link. Re-calling `conv_configure_email_channel` for updates keeps the stored passwords — non-secret fields can be changed freely.\n\n## Step 2 — create the channel\n\nCall `conv_configure_email_channel` (admin):\n\n```jsonc\n{\n \"name\": \"Acme Support\",\n \"config\": {\n \"addressing\": {\n \"fromAddress\": \"support@acme.com\",\n \"fromName\": \"Acme Support\",\n \"replyToTemplate\": \"support+conv-{conversationId}@acme.com\"\n },\n \"outbound\": {\n \"provider\": \"smtp\",\n \"host\": \"smtp.acme.com\",\n \"port\": 587,\n \"secure\": false,\n \"username\": \"support@acme.com\"\n },\n \"inbound\": {\n \"provider\": \"imap\",\n \"host\": \"imap.acme.com\",\n \"port\": 993,\n \"secure\": true,\n \"username\": \"support@acme.com\",\n \"mailbox\": \"INBOX\"\n }\n }\n}\n```\n\nReturns the channel ID, type `'email'`, the redacted DTO (passwords show as `••••`), and — when SMTP or IMAP is configured — a `credentialLink` with a one-time URL. The channel stays `active: false` until the human opens the link and saves the passwords; saving verifies them against the SMTP/IMAP servers and activates the channel.\n\nTo **update** an existing channel, pass `channelId` and only the fields you want to change. Stored passwords are preserved; to rotate a password, mint a link with `conv_request_channel_credentials`.\n\n## Step 3 — verify\n\nCall `conv_test_email_channel` with `{ channelId }`. It performs:\n\n- An SMTP `verify()` (no mail sent).\n- An IMAP `connect()` then `logout()` (no fetch).\n\nReturns `{ smtp: 'ok' | 'error: ', imap: 'ok' | 'error: ' | 'not configured' }`.\n\nIf `smtp` reports auth failure, the most common causes are app-password-required (Gmail, iCloud), the wrong port for the cipher (587 STARTTLS vs 465 implicit-TLS), or a region-specific endpoint (Microsoft 365 enforces `smtp.office365.com`).\n\n## Step 4 — confirm registration\n\nCall `conv_list_channels`. Look for the new row with `type: 'email'`, `active: true`, and the addressing block.\n\n## What happens next\n\n- **Outbound**: when an admin uses `conv_send_message` on a conversation tied to this channel, the message is enqueued in `conv_message_deliveries`. The `OutboundDeliveryWorker` drains that queue and the email adapter sends via SMTP (or the Mailer).\n- **Inbound** (if IMAP is configured): the `InboundPollWorker` ticks every 60s, fetches new UIDs, threads each message into an existing conversation (via `In-Reply-To` + `References` headers) or opens a new one. End-user senders are auto-created as `conv_contacts`.\n\n## Troubleshooting\n\n- **No outbound delivery** — check `conv_message_deliveries` rows for the channel. `status='dead'` means 5 attempts failed; the `error` column has the SMTP response. `conv_test_email_channel` is the fastest way to check creds.\n- **Inbound stuck** — `conv_inbound_state.cursor.lastUid` shows the high-water mark; `last_polled_at` shows the most recent tick; `last_error` carries any IMAP error.\n- **Email lands in spam at the recipient** — confirm SPF / DKIM / DMARC for the `fromAddress` domain. Munin doesn't manage DNS.\n" }, { "uri": "skill://conv/setup-voice-sms-channel", @@ -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## 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" + "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_voice_sms_vendors` — see the supported vendors and each one's config fields; fields marked `secret: true` are never passed by you.\n2. `conv_configure_voice_sms_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_voice_sms_channel` re-verifies stored credentials any time; `conv_send_voice_sms_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_voice_sms_channel`, `conv_send_voice_sms_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_voice_sms_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_voice_sms_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_voice_sms_channel { channelId }` — vendor-shaped credential check (Twilio account fetch, MessageBird balance, etc.), no message sent.\n- SMS: `conv_send_voice_sms_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", @@ -213,7 +213,7 @@ "title": "CRM: Deduplicate contacts", "description": "Find duplicate contacts (same person, multiple rows) and consolidate them. There is no merge tool — this skill documents the manual reconcile pattern.", "mimeType": "text/markdown", - "content": "# Deduplicate contacts\n`crm_bulk_create_contacts` skips on email/phone match, but legacy data and human entry create overlap anyway. There is **no `crm_merge_contacts` tool today** — duplicates must be reconciled manually by promoting one row as the keeper, copying useful fields onto it, and re-pointing activities/deals.\n\n## TL;DR\n\n1. `crm_list_contacts` (paginated) — pull the full list.\n2. Group by email and phone in your own buffer; flag clusters with >1 row.\n3. For each cluster: pick a keeper, merge fields onto it via `crm_update_contact`, then leave the duplicates as orphans (or tag them for archival).\n4. Log a reconciliation note on the keeper so the audit trail is clear.\n\n## Step 1 — pull contacts\n\n```jsonc\n{ \"name\": \"crm_list_contacts\", \"arguments\": { \"limit\": 200 } }\n```\n\n`limit` caps at 200; iterate with smaller groups if the org is larger (the tool returns newest-updated-first; pagination is via `companyId`/`tag` filters since there's no offset cursor today).\n\nFor very large orgs, narrow by `tag` or `companyId` first.\n\n## Step 2 — find duplicates\n\nIn your buffer, group by:\n- Lowercased trimmed `email`\n- E.164-normalized `phone`\n\nCluster sizes ≥ 2 are duplicates. Note that two real people sharing `support@acme.com` is legitimate — eyeball the cluster before merging.\n\n`crm_find_contact` is helpful for spot checks but not for full scans:\n\n```jsonc\n{ \"name\": \"crm_find_contact\", \"arguments\": { \"email\": \"vita@acme.com\" } }\n```\n\n## Step 3 — pick the keeper\n\nHeuristics, in order:\n1. The one with `endUserId` set (linked to a real auth user — never delete this row).\n2. Most recent `lastContactedAt`.\n3. Most complete fields (most non-null values).\n4. Oldest `createdAt` (preserves the original system-of-record row).\n\n## Step 4 — gather context on the duplicates\n\nBefore merging, list activities and deals per duplicate so nothing's lost:\n\n```jsonc\n{ \"name\": \"crm_list_activities\", \"arguments\": { \"contactId\": \"\", \"limit\": 200 } }\n```\n\nNote: there is no built-in \"reassign activities to the keeper\" tool. Activities stay on whatever contactId they were logged under. Document that fact in your reconciliation note.\n\n## Step 5 — merge fields onto the keeper\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": {\n \"id\": \"\",\n \"patch\": {\n \"name\": \"\",\n \"phone\": \"\",\n \"title\": \"\",\n \"address\": \"\",\n \"tags\": [\"\"],\n \"customFields\": { \"\": \"...\" }\n }\n }\n}\n```\n\nDon't change `email` (it's the dedup key); the keeper should already have the canonical email.\n\n## Step 6 — flag the duplicates\n\nThere's no soft-delete tool. Tag duplicates so they're filterable but not actively used:\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": {\n \"id\": \"\",\n \"patch\": {\n \"tags\": [\"dedup-archived-2026-05\"],\n \"customFields\": { \"mergedInto\": \"\", \"mergedAt\": \"2026-05-01\" }\n }\n }\n}\n```\n\nOptionally set `doNotContact: true` so future bulk imports skip them.\n\n## Step 7 — log on the keeper\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"type\": \"note\",\n \"subject\": \"Merged duplicates\",\n \"body\": \"Consolidated rows , into this contact. Activities on those rows remain on their original contactId.\",\n \"contactId\": \"\",\n \"metadata\": { \"mergedFrom\": [\"\", \"\"] }\n }\n}\n```\n\n## What NOT to do\n\n- **Don't delete duplicate contacts.** There's no cascading delete that's safe — activities, deals, and history would orphan or break. Tag-and-archive is the supported pattern.\n- **Don't change the email on a duplicate to \"park\" it.** That makes it findable as a fresh contact in the next import, which recreates the duplicate problem.\n- **Don't bulk-merge without confirming.** Two people on a shared inbox (`info@acme.com`) is a real shape. Prompt the operator before merging clusters where names differ significantly.\n- **Don't expect activities to follow the merge.** They stay on the original contactId. Make this clear in your log on the keeper.\n\n## Future work\n\nIf this skill is being run frequently, propose a first-class `crm_merge_contacts` tool that atomically: copies fields, reassigns activities + deals + endUser link, and soft-deletes the source contact. Until then, the manual pattern above is the supported path.\n\n## Related\n\n- `skill://crm/import-and-score-leads` — bulk import that already dedupes against existing contacts.\n- `skill://crm/onboard-new-customer` — single-contact dedupe pattern using `crm_find_contact`.\n" + "content": "# Deduplicate contacts\n`crm_bulk_create_contacts` skips on email/phone match, but legacy data and human entry create overlap anyway. There is **no `crm_merge_contacts` tool today** — duplicates must be reconciled manually by promoting one row as the keeper, copying useful fields onto it, and re-pointing activities/deals.\n\n## TL;DR\n\n1. `crm_list_contacts` (paginated) — pull the full list.\n2. Group by email and phone in your own buffer; flag clusters with >1 row.\n3. For each cluster: pick a keeper, merge fields onto it via `crm_update_contact`, then leave the duplicates as orphans (or tag them for archival).\n4. Log a reconciliation note on the keeper so the audit trail is clear.\n\n## Step 1 — pull contacts\n\n```jsonc\n{ \"name\": \"crm_list_contacts\", \"arguments\": { \"limit\": 200 } }\n```\n\n`limit` caps at 200; iterate with smaller groups if the org is larger (the tool returns newest-updated-first; pagination is via `companyId`/`tag` filters since there's no offset cursor today).\n\nFor very large orgs, narrow by `tag` or `companyId` first.\n\n## Step 2 — find duplicates\n\nIn your buffer, group by:\n- Lowercased trimmed `email`\n- E.164-normalized `phone`\n\nCluster sizes ≥ 2 are duplicates. Note that two real people sharing `support@acme.com` is legitimate — eyeball the cluster before merging.\n\n`crm_lookup_contact` is helpful for spot checks but not for full scans:\n\n```jsonc\n{ \"name\": \"crm_lookup_contact\", \"arguments\": { \"email\": \"vita@acme.com\" } }\n```\n\n## Step 3 — pick the keeper\n\nHeuristics, in order:\n1. The one with `endUserId` set (linked to a real auth user — never delete this row).\n2. Most recent `lastContactedAt`.\n3. Most complete fields (most non-null values).\n4. Oldest `createdAt` (preserves the original system-of-record row).\n\n## Step 4 — gather context on the duplicates\n\nBefore merging, list activities and deals per duplicate so nothing's lost:\n\n```jsonc\n{ \"name\": \"crm_list_activities\", \"arguments\": { \"contactId\": \"\", \"limit\": 200 } }\n```\n\nNote: there is no built-in \"reassign activities to the keeper\" tool. Activities stay on whatever contactId they were logged under. Document that fact in your reconciliation note.\n\n## Step 5 — merge fields onto the keeper\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": {\n \"id\": \"\",\n \"patch\": {\n \"name\": \"\",\n \"phone\": \"\",\n \"title\": \"\",\n \"address\": \"\",\n \"tags\": [\"\"],\n \"customFields\": { \"\": \"...\" }\n }\n }\n}\n```\n\nDon't change `email` (it's the dedup key); the keeper should already have the canonical email.\n\n## Step 6 — flag the duplicates\n\nThere's no soft-delete tool. Tag duplicates so they're filterable but not actively used:\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": {\n \"id\": \"\",\n \"patch\": {\n \"tags\": [\"dedup-archived-2026-05\"],\n \"customFields\": { \"mergedInto\": \"\", \"mergedAt\": \"2026-05-01\" }\n }\n }\n}\n```\n\nOptionally set `doNotContact: true` so future bulk imports skip them.\n\n## Step 7 — log on the keeper\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"type\": \"note\",\n \"subject\": \"Merged duplicates\",\n \"body\": \"Consolidated rows , into this contact. Activities on those rows remain on their original contactId.\",\n \"contactId\": \"\",\n \"metadata\": { \"mergedFrom\": [\"\", \"\"] }\n }\n}\n```\n\n## What NOT to do\n\n- **Don't delete duplicate contacts.** There's no cascading delete that's safe — activities, deals, and history would orphan or break. Tag-and-archive is the supported pattern.\n- **Don't change the email on a duplicate to \"park\" it.** That makes it findable as a fresh contact in the next import, which recreates the duplicate problem.\n- **Don't bulk-merge without confirming.** Two people on a shared inbox (`info@acme.com`) is a real shape. Prompt the operator before merging clusters where names differ significantly.\n- **Don't expect activities to follow the merge.** They stay on the original contactId. Make this clear in your log on the keeper.\n\n## Future work\n\nIf this skill is being run frequently, propose a first-class `crm_merge_contacts` tool that atomically: copies fields, reassigns activities + deals + endUser link, and soft-deletes the source contact. Until then, the manual pattern above is the supported path.\n\n## Related\n\n- `skill://crm/import-and-score-leads` — bulk import that already dedupes against existing contacts.\n- `skill://crm/onboard-new-customer` — single-contact dedupe pattern using `crm_lookup_contact`.\n" }, { "uri": "skill://crm/extract-contact-from-message", @@ -222,7 +222,7 @@ "title": "CRM: Extract a contact from a message", "description": "When a conversation closes, read the thread for identifying info the end-user volunteered (name, email, phone, company, title) and persist it to the CRM as a contact — auto-applied, no proposal queue. Designed to fire on every `conversation.closed` event so visitor-volunteered identity becomes structured CRM data without operator intervention.", "mimeType": "text/markdown", - "content": "# Extract a contact from a message\nPeople volunteer identity in chat. \"Hi, this is Jane from Acme — could you …\", \"ping me at jane@acme.com\", \"my number is +47 555-1234\". That information is the difference between a row called \"anonymous visitor #4912\" and a real CRM contact you can re-engage later. Your job is to read one closed conversation, extract whatever the end-user actually said about themselves, and write it through to the CRM — no proposal queue, no review step. The data source is the user's own message; if they typed it, that's authoritative enough.\n\nA separate, scheduled `skill://crm/clean-contact-data` curator runs weekly to merge any duplicates this pass creates (e.g. visitor gives email in conv #1 and phone in conv #2 with no overlap). Don't try to do hygiene's job here — keep the per-conversation pass narrow.\n\n## TL;DR\n\n1. **Read the conversation** with `conv_get_conversation()`. The user prompt names the conversation; do not list or scan others.\n2. **Check the first end-user message's `metadata.senderClassification`** (email channel only). If `isMailingList`, `isAutoReply`, or `isBounce` is true → skip the conversation entirely, no writes. These aren't real people we should be CRM-tracking.\n3. **Extract identifying fields** from `end_user`-authored messages only: `email`, `phone`, `name`, `companyId`/`companyName`, `title`. Ignore agent and system messages — those are operator output, not user-volunteered identity. **For email messages, also read `metadata.signatureText`** — the trailing block already pulled out of the body. Signatures are the strongest identity source we have (the sender themselves typed it as their canonical \"this is who I am\") and should take precedence over inline mentions in the body.\n4. **Skip if nothing identifying was said.** If you found neither email nor phone nor a clear self-introduced name, finish silently — no writes.\n5. **Look up an existing contact** with `crm_find_contact({ email?, phone? })` before creating. Match keys: extracted email or phone.\n6. **Create or backfill, never overwrite.** If `crm_find_contact` returns null → `crm_create_contact` with the extracted fields and the conversation's `endUserId`. If it returns an existing row → `crm_update_contact` and ONLY fill fields that are currently null/empty on the existing row. Do not overwrite a non-empty field.\n7. **Stop.** One `crm_create_contact` or one `crm_update_contact`. No further actions.\n\n### Role accounts (email only)\n\nWhen `metadata.senderClassification.isRoleAccount` is true on the first end-user message, the address is a shared inbox (`support@`, `sales@`, `info@`, `noreply@`, …) — not a person. Extract `email` only; **do not** write `name`, `title`, or `phone` from the signature or the body, because those fields rotate week to week as different humans answer the inbox. Tag with `from-chat` and `role-account` so operators can filter or merge later.\n\n## Step 1 — read the conversation\n\n```jsonc\n{ \"name\": \"conv_get_conversation\", \"arguments\": { \"id\": \"ccv_…\" } }\n```\n\nThe response includes `messages[]`. The conversation summary also has `endUserId` — note it; you'll pass it on `crm_create_contact` so the new contact links back to the conversation participant.\n\n## Step 2 — extract from end-user messages\n\nOnly consider `authorType: \"end_user\"` messages. Look in two places, in priority order:\n\n1. **`metadata.signatureText` (email channel only).** A signature block is the sender's own canonical self-description — a strong identity claim. Parse it as a structured block: name on its own line (sometimes preceded by a closing like \"Best,\"), then any combination of title, company, address, phone, email, website. Lines like \"Sent from my iPhone\" carry no identity; ignore them.\n2. **Inline body mentions** (\"Hi, I'm Jane from Acme\", \"ping me at +47 …\"). Same rules as before — only self-referential, not third-party routing instructions.\n\nFor each field:\n\n- **Email** — From-address (`conv_get_conversation` returns it on the conversation; it's the From of every end-user email). Inline mentions are secondary. Skip emails that look like third parties they're forwarding to (\"send it to legal@partner.com\").\n- **Phone** — signature first, then inline. Normalise to E.164 if you can infer the country from context; otherwise keep as typed.\n- **Name** — signature first (the line right after the closing, e.g. \"Best,\\nJane Doe\"). Otherwise only when the user explicitly self-introduces (\"I'm Jane\", \"this is Jane Doe\"). Don't infer from email local-part — `j.doe@acme.com` doesn't tell you they're \"Jane Doe\". Don't pick up names of third parties they're discussing.\n- **Title / company** — signature first (typical layout: name → title → company). Otherwise only when self-stated (\"I'm head of ops at Acme\"). Don't infer company from email domain alone unless they also self-introduce: \"I'm Jane from Acme\" + `jane@acme.com` is fine, just `jane@acme.com` is not.\n\nIf a message contains \"send to support@example.com please\" and nothing self-referential, **skip the email** — that's a routing instruction, not an identity claim.\n\n### Reading the signature block\n\n`metadata.signatureText` is the raw block as the sender typed it. A typical shape:\n\n```\nBest regards,\nJane Doe\nHead of Operations\nAcme Corp\n+1 555 123 4567\njane@acme.com\n```\n\nParse line by line. The first non-closing line is the name. Subsequent lines are some mix of title, company, phone, email, address — order varies. Be conservative on ambiguous lines (e.g. \"London\" alone — is that an address fragment? a city? skip it).\n\n## Step 3 — when to skip entirely\n\n- The first end-user message's `metadata.senderClassification` has `isMailingList`, `isAutoReply`, or `isBounce` set to true → skip. These are not human correspondents.\n- No email AND no phone AND no clear self-introduced name → skip, no writes.\n- Conversation has no `endUserId` (channel doesn't track participants) → skip; nothing to attribute the contact to.\n- The conversation's `endUserId` already maps to a `crm_contacts` row (via `endUserId` join) AND that row already has email + phone + name populated → skip, nothing to backfill.\n\n## Step 4 — dedupe before creating\n\n```jsonc\n{ \"name\": \"crm_find_contact\", \"arguments\": { \"email\": \"jane@acme.com\" } }\n```\n\nOr by phone if no email. If `crm_find_contact` returns `null`, you'll create a new contact in step 5. Otherwise, you'll backfill.\n\nIf you have BOTH email and phone, run `crm_find_contact` for email first; if no hit, run for phone. The first hit wins.\n\n## Step 5a — create (no existing match)\n\n```jsonc\n{\n \"name\": \"crm_create_contact\",\n \"arguments\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@acme.com\",\n \"phone\": \"+47 555-1234\",\n \"title\": \"Head of Ops\",\n \"endUserId\": \"eu_...\",\n \"tags\": [\"from-chat\"]\n }\n}\n```\n\nPass only the fields you actually extracted. Always include `endUserId` (the conversation's participant) so the contact is linked back to the chat surface — the dashboard's conversation view uses that link to show \"this person's CRM record\".\n\nAdd the `from-chat` tag so operators can filter contacts that came in via this curator.\n\n## Step 5b — backfill (existing match found)\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": {\n \"id\": \"cct_existing\",\n \"patch\": { \"phone\": \"+47 555-1234\", \"title\": \"Head of Ops\" },\n \"mode\": \"fill-null\"\n }\n}\n```\n\n**Always pass `mode: \"fill-null\"`** from this skill. That tells the service to apply patch keys only where the existing contact's field is null/empty; non-null fields are left untouched server-side. This is defense-in-depth on top of your own filtering — if you accidentally include a field that's already populated, the service refuses to overwrite rather than clobbering operator-curated data.\n\nYou should still pre-filter the patch yourself (it's cleaner and avoids a no-op write): read the row from `crm_find_contact`'s response and, for each extracted field:\n\n- existing field is `null` or empty string → include it in `patch`\n- existing field has a value → leave it out of `patch`, even if the user's new value looks \"better\"\n\nIf every extracted field is already populated, finish silently without calling `crm_update_contact` at all.\n\n## What NOT to do\n\n- **Don't overwrite human-curated data.** The operator typed something, then the user typed something different in chat — trust the operator. Backfill empty fields only.\n- **Don't extract from agent messages.** \"Sure, jane@acme.com is on file\" is the agent quoting back something it remembers; treating it as an identity claim creates feedback loops.\n- **Don't infer.** `j.doe@acme.com` does not mean their name is \"Jane Doe\" or \"John Doe\". Names come from explicit self-introduction; everything else stays null.\n- **Don't propose merges.** That's `skill://crm/clean-contact-data`'s job and runs weekly across the whole population. Per-conversation extraction is narrow on purpose.\n- **Don't open multiple records.** One contact per pass — either one create or one update, never both.\n\n## Related\n\n- `skill://crm/clean-contact-data` — population-level merge pass that catches dupes this skill missed (e.g. visitor gave email in one conv, phone in another).\n- `skill://kb/review-content` — the symmetric pattern: per-conversation event-driven extraction, but for KB candidates instead of CRM contacts. Note that KB *proposes* (because LLM-drafted facts can be wrong); CRM contact extraction *auto-applies* (because the data source is the user's own typed words).\n" + "content": "# Extract a contact from a message\nPeople volunteer identity in chat. \"Hi, this is Jane from Acme — could you …\", \"ping me at jane@acme.com\", \"my number is +47 555-1234\". That information is the difference between a row called \"anonymous visitor #4912\" and a real CRM contact you can re-engage later. Your job is to read one closed conversation, extract whatever the end-user actually said about themselves, and write it through to the CRM — no proposal queue, no review step. The data source is the user's own message; if they typed it, that's authoritative enough.\n\nA separate, scheduled `skill://crm/clean-contact-data` curator runs weekly to merge any duplicates this pass creates (e.g. visitor gives email in conv #1 and phone in conv #2 with no overlap). Don't try to do hygiene's job here — keep the per-conversation pass narrow.\n\n## TL;DR\n\n1. **Read the conversation** with `conv_get_conversation()`. The user prompt names the conversation; do not list or scan others.\n2. **Check the first end-user message's `metadata.senderClassification`** (email channel only). If `isMailingList`, `isAutoReply`, or `isBounce` is true → skip the conversation entirely, no writes. These aren't real people we should be CRM-tracking.\n3. **Extract identifying fields** from `end_user`-authored messages only: `email`, `phone`, `name`, `companyId`/`companyName`, `title`. Ignore agent and system messages — those are operator output, not user-volunteered identity. **For email messages, also read `metadata.signatureText`** — the trailing block already pulled out of the body. Signatures are the strongest identity source we have (the sender themselves typed it as their canonical \"this is who I am\") and should take precedence over inline mentions in the body.\n4. **Skip if nothing identifying was said.** If you found neither email nor phone nor a clear self-introduced name, finish silently — no writes.\n5. **Look up an existing contact** with `crm_lookup_contact({ email?, phone? })` before creating. Match keys: extracted email or phone.\n6. **Create or backfill, never overwrite.** If `crm_lookup_contact` returns null → `crm_create_contact` with the extracted fields and the conversation's `endUserId`. If it returns an existing row → `crm_update_contact` and ONLY fill fields that are currently null/empty on the existing row. Do not overwrite a non-empty field.\n7. **Stop.** One `crm_create_contact` or one `crm_update_contact`. No further actions.\n\n### Role accounts (email only)\n\nWhen `metadata.senderClassification.isRoleAccount` is true on the first end-user message, the address is a shared inbox (`support@`, `sales@`, `info@`, `noreply@`, …) — not a person. Extract `email` only; **do not** write `name`, `title`, or `phone` from the signature or the body, because those fields rotate week to week as different humans answer the inbox. Tag with `from-chat` and `role-account` so operators can filter or merge later.\n\n## Step 1 — read the conversation\n\n```jsonc\n{ \"name\": \"conv_get_conversation\", \"arguments\": { \"id\": \"ccv_…\" } }\n```\n\nThe response includes `messages[]`. The conversation summary also has `endUserId` — note it; you'll pass it on `crm_create_contact` so the new contact links back to the conversation participant.\n\n## Step 2 — extract from end-user messages\n\nOnly consider `authorType: \"end_user\"` messages. Look in two places, in priority order:\n\n1. **`metadata.signatureText` (email channel only).** A signature block is the sender's own canonical self-description — a strong identity claim. Parse it as a structured block: name on its own line (sometimes preceded by a closing like \"Best,\"), then any combination of title, company, address, phone, email, website. Lines like \"Sent from my iPhone\" carry no identity; ignore them.\n2. **Inline body mentions** (\"Hi, I'm Jane from Acme\", \"ping me at +47 …\"). Same rules as before — only self-referential, not third-party routing instructions.\n\nFor each field:\n\n- **Email** — From-address (`conv_get_conversation` returns it on the conversation; it's the From of every end-user email). Inline mentions are secondary. Skip emails that look like third parties they're forwarding to (\"send it to legal@partner.com\").\n- **Phone** — signature first, then inline. Normalise to E.164 if you can infer the country from context; otherwise keep as typed.\n- **Name** — signature first (the line right after the closing, e.g. \"Best,\\nJane Doe\"). Otherwise only when the user explicitly self-introduces (\"I'm Jane\", \"this is Jane Doe\"). Don't infer from email local-part — `j.doe@acme.com` doesn't tell you they're \"Jane Doe\". Don't pick up names of third parties they're discussing.\n- **Title / company** — signature first (typical layout: name → title → company). Otherwise only when self-stated (\"I'm head of ops at Acme\"). Don't infer company from email domain alone unless they also self-introduce: \"I'm Jane from Acme\" + `jane@acme.com` is fine, just `jane@acme.com` is not.\n\nIf a message contains \"send to support@example.com please\" and nothing self-referential, **skip the email** — that's a routing instruction, not an identity claim.\n\n### Reading the signature block\n\n`metadata.signatureText` is the raw block as the sender typed it. A typical shape:\n\n```\nBest regards,\nJane Doe\nHead of Operations\nAcme Corp\n+1 555 123 4567\njane@acme.com\n```\n\nParse line by line. The first non-closing line is the name. Subsequent lines are some mix of title, company, phone, email, address — order varies. Be conservative on ambiguous lines (e.g. \"London\" alone — is that an address fragment? a city? skip it).\n\n## Step 3 — when to skip entirely\n\n- The first end-user message's `metadata.senderClassification` has `isMailingList`, `isAutoReply`, or `isBounce` set to true → skip. These are not human correspondents.\n- No email AND no phone AND no clear self-introduced name → skip, no writes.\n- Conversation has no `endUserId` (channel doesn't track participants) → skip; nothing to attribute the contact to.\n- The conversation's `endUserId` already maps to a `crm_contacts` row (via `endUserId` join) AND that row already has email + phone + name populated → skip, nothing to backfill.\n\n## Step 4 — dedupe before creating\n\n```jsonc\n{ \"name\": \"crm_lookup_contact\", \"arguments\": { \"email\": \"jane@acme.com\" } }\n```\n\nOr by phone if no email. If `crm_lookup_contact` returns `null`, you'll create a new contact in step 5. Otherwise, you'll backfill.\n\nIf you have BOTH email and phone, run `crm_lookup_contact` for email first; if no hit, run for phone. The first hit wins.\n\n## Step 5a — create (no existing match)\n\n```jsonc\n{\n \"name\": \"crm_create_contact\",\n \"arguments\": {\n \"name\": \"Jane Doe\",\n \"email\": \"jane@acme.com\",\n \"phone\": \"+47 555-1234\",\n \"title\": \"Head of Ops\",\n \"endUserId\": \"eu_...\",\n \"tags\": [\"from-chat\"]\n }\n}\n```\n\nPass only the fields you actually extracted. Always include `endUserId` (the conversation's participant) so the contact is linked back to the chat surface — the dashboard's conversation view uses that link to show \"this person's CRM record\".\n\nAdd the `from-chat` tag so operators can filter contacts that came in via this curator.\n\n## Step 5b — backfill (existing match found)\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": {\n \"id\": \"cct_existing\",\n \"patch\": { \"phone\": \"+47 555-1234\", \"title\": \"Head of Ops\" },\n \"mode\": \"fill-null\"\n }\n}\n```\n\n**Always pass `mode: \"fill-null\"`** from this skill. That tells the service to apply patch keys only where the existing contact's field is null/empty; non-null fields are left untouched server-side. This is defense-in-depth on top of your own filtering — if you accidentally include a field that's already populated, the service refuses to overwrite rather than clobbering operator-curated data.\n\nYou should still pre-filter the patch yourself (it's cleaner and avoids a no-op write): read the row from `crm_lookup_contact`'s response and, for each extracted field:\n\n- existing field is `null` or empty string → include it in `patch`\n- existing field has a value → leave it out of `patch`, even if the user's new value looks \"better\"\n\nIf every extracted field is already populated, finish silently without calling `crm_update_contact` at all.\n\n## What NOT to do\n\n- **Don't overwrite human-curated data.** The operator typed something, then the user typed something different in chat — trust the operator. Backfill empty fields only.\n- **Don't extract from agent messages.** \"Sure, jane@acme.com is on file\" is the agent quoting back something it remembers; treating it as an identity claim creates feedback loops.\n- **Don't infer.** `j.doe@acme.com` does not mean their name is \"Jane Doe\" or \"John Doe\". Names come from explicit self-introduction; everything else stays null.\n- **Don't propose merges.** That's `skill://crm/clean-contact-data`'s job and runs weekly across the whole population. Per-conversation extraction is narrow on purpose.\n- **Don't open multiple records.** One contact per pass — either one create or one update, never both.\n\n## Related\n\n- `skill://crm/clean-contact-data` — population-level merge pass that catches dupes this skill missed (e.g. visitor gave email in one conv, phone in another).\n- `skill://kb/review-content` — the symmetric pattern: per-conversation event-driven extraction, but for KB candidates instead of CRM contacts. Note that KB *proposes* (because LLM-drafted facts can be wrong); CRM contact extraction *auto-applies* (because the data source is the user's own typed words).\n" }, { "uri": "skill://crm/import-and-score-leads", @@ -231,7 +231,7 @@ "title": "CRM: Import and score leads", "description": "Bulk-import contacts, attach them to companies and a deal pipeline, log the source touchpoint, and seed AI summaries for downstream prioritization.", "mimeType": "text/markdown", - "content": "# Import and score leads\nWhen a customer hands you a list of leads (CSV from a webinar, scraped from an event registration, exported from a marketing tool), you want to land them in the CRM **without duplicates**, attach them to companies, link them to a sales pipeline, and seed an AI summary that drives later prioritization.\n\n## TL;DR\n\n1. `crm_bulk_create_contacts` — built-in dedup against email + phone + do-not-contact list.\n2. For each new contact's company: `crm_list_companies` then `crm_create_company` if missing; `crm_update_contact` to link.\n3. For each lead worth a sales motion: `crm_create_deal` against the right `pipelineId`.\n4. `crm_log_activity` on each new contact so the touchpoint shows up in their timeline.\n5. `crm_set_ai_summary` on each contact (and deal) so prioritization signals are populated.\n\n## Step 1 — bulk import with built-in dedup\n\n```jsonc\n{\n \"name\": \"crm_bulk_create_contacts\",\n \"arguments\": {\n \"contacts\": [\n {\n \"name\": \"Vita Costa\",\n \"email\": \"vita@acme.com\",\n \"phone\": \"+4799887766\",\n \"title\": \"Head of Ops\",\n \"tags\": [\"webinar-2026-04\"],\n \"customFields\": { \"leadSource\": \"spring-webinar\" }\n },\n { \"...up to 500 rows...\" : true }\n ]\n }\n}\n```\n\nReturns `{ created: , skipped: }`. Skipped rows match an existing contact by email/phone, **or** match a `doNotContact: true` contact (compliance). The tool does **not** tell you which rows were skipped — if you need to know, `crm_find_contact` per row first (slower but explicit).\n\nHard cap: 500 contacts per call. Chunk larger lists.\n\n## Step 2 — companies\n\nFor each unique company domain in your list:\n\n```jsonc\n{ \"name\": \"crm_list_companies\", \"arguments\": { \"limit\": 200 } }\n```\n\nIf the company doesn't exist:\n\n```jsonc\n{\n \"name\": \"crm_create_company\",\n \"arguments\": { \"name\": \"Acme Inc.\", \"domain\": \"acme.com\", \"tags\": [\"customer\"] }\n}\n```\n\nThen attach each contact to its company:\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": { \"id\": \"\", \"patch\": { \"companyId\": \"\" } }\n}\n```\n\n## Step 3 — pipeline + deals\n\n```jsonc\n{ \"name\": \"crm_list_pipelines\", \"arguments\": {} }\n```\n\nPick the right pipeline. For each lead worth pursuing:\n\n```jsonc\n{\n \"name\": \"crm_create_deal\",\n \"arguments\": {\n \"name\": \"Acme — spring webinar follow-up\",\n \"pipelineId\": \"\",\n \"primaryContactId\": \"\",\n \"companyId\": \"\",\n \"amountCents\": 500000,\n \"currency\": \"USD\",\n \"expectedCloseAt\": \"2026-07-31T00:00:00Z\"\n }\n}\n```\n\nIf `stageId` is omitted, the deal lands in the pipeline's first stage. Use `crm_change_stage` later to advance — see `skill://crm/progress-deal-through-pipeline`.\n\n## Step 4 — log the source touchpoint\n\nPer imported contact, log what brought them in:\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"type\": \"note\",\n \"subject\": \"Source: Spring 2026 webinar\",\n \"body\": \"Attended the April 24 product demo. Asked about EU data residency.\",\n \"contactId\": \"\",\n \"metadata\": { \"leadSource\": \"spring-webinar\" }\n }\n}\n```\n\nSetting `contactId` bumps the contact's `lastContactedAt` automatically — the timeline UI uses this for sorting. Activity types: `note | call | email | meeting | task`.\n\n## Step 5 — AI summary for prioritization\n\n```jsonc\n{\n \"name\": \"crm_set_ai_summary\",\n \"arguments\": {\n \"entityType\": \"contact\",\n \"id\": \"\",\n \"summary\": \"Head of Ops at a 200-person fintech. Engaged on data-residency questions during the webinar — buyer signal. Likely budget cycle Q3.\",\n \"nextAction\": \"Send EU data-residency one-pager + propose a 30-min discovery call.\"\n }\n}\n```\n\nBoth `summary` and `nextAction` are free-text. `null` clears the field. Also set on the deal if you created one (`entityType: \"deal\"`).\n\n## What NOT to do\n\n- **Don't bulk-create without a tag.** Tag the import (`tags: [\"webinar-2026-04\"]`) so it's trivial to find or revert in `crm_list_contacts({ tag: \"...\" })` if the source turns out to be junk.\n- **Don't skip the AI summary step.** Downstream prioritization (and humans skimming the list) read it. An empty summary is a buried lead.\n- **Don't paste raw CSV into `customFields`.** The map is freeform but lives in the contact row forever — only what the team will actually filter on belongs there.\n- **Don't import without checking compliance.** GDPR / opt-in: confirm the source had explicit consent before logging the touchpoint as `email` or `call`.\n\n## Related\n\n- `skill://crm/onboard-new-customer` — slower path for a single named customer.\n- `skill://crm/deduplicate-contacts` — what to do when a list has overlap with existing contacts.\n- `skill://crm/progress-deal-through-pipeline` — moving the new deals through stages.\n- `skill://playbooks/customer-acquisition` — end-to-end CRM + Conv flow that starts from this import.\n" + "content": "# Import and score leads\nWhen a customer hands you a list of leads (CSV from a webinar, scraped from an event registration, exported from a marketing tool), you want to land them in the CRM **without duplicates**, attach them to companies, link them to a sales pipeline, and seed an AI summary that drives later prioritization.\n\n## TL;DR\n\n1. `crm_bulk_create_contacts` — built-in dedup against email + phone + do-not-contact list.\n2. For each new contact's company: `crm_list_companies` then `crm_create_company` if missing; `crm_update_contact` to link.\n3. For each lead worth a sales motion: `crm_create_deal` against the right `pipelineId`.\n4. `crm_log_activity` on each new contact so the touchpoint shows up in their timeline.\n5. `crm_set_ai_summary` on each contact (and deal) so prioritization signals are populated.\n\n## Step 1 — bulk import with built-in dedup\n\n```jsonc\n{\n \"name\": \"crm_bulk_create_contacts\",\n \"arguments\": {\n \"contacts\": [\n {\n \"name\": \"Vita Costa\",\n \"email\": \"vita@acme.com\",\n \"phone\": \"+4799887766\",\n \"title\": \"Head of Ops\",\n \"tags\": [\"webinar-2026-04\"],\n \"customFields\": { \"leadSource\": \"spring-webinar\" }\n },\n { \"...up to 500 rows...\" : true }\n ]\n }\n}\n```\n\nReturns `{ created: , skipped: }`. Skipped rows match an existing contact by email/phone, **or** match a `doNotContact: true` contact (compliance). The tool does **not** tell you which rows were skipped — if you need to know, `crm_lookup_contact` per row first (slower but explicit).\n\nHard cap: 500 contacts per call. Chunk larger lists.\n\n## Step 2 — companies\n\nFor each unique company domain in your list:\n\n```jsonc\n{ \"name\": \"crm_list_companies\", \"arguments\": { \"limit\": 200 } }\n```\n\nIf the company doesn't exist:\n\n```jsonc\n{\n \"name\": \"crm_create_company\",\n \"arguments\": { \"name\": \"Acme Inc.\", \"domain\": \"acme.com\", \"tags\": [\"customer\"] }\n}\n```\n\nThen attach each contact to its company:\n\n```jsonc\n{\n \"name\": \"crm_update_contact\",\n \"arguments\": { \"id\": \"\", \"patch\": { \"companyId\": \"\" } }\n}\n```\n\n## Step 3 — pipeline + deals\n\n```jsonc\n{ \"name\": \"crm_list_pipelines\", \"arguments\": {} }\n```\n\nPick the right pipeline. For each lead worth pursuing:\n\n```jsonc\n{\n \"name\": \"crm_create_deal\",\n \"arguments\": {\n \"name\": \"Acme — spring webinar follow-up\",\n \"pipelineId\": \"\",\n \"primaryContactId\": \"\",\n \"companyId\": \"\",\n \"amountCents\": 500000,\n \"currency\": \"USD\",\n \"expectedCloseAt\": \"2026-07-31T00:00:00Z\"\n }\n}\n```\n\nIf `stageId` is omitted, the deal lands in the pipeline's first stage. Use `crm_change_deal_stage` later to advance — see `skill://crm/progress-deal-through-pipeline`.\n\n## Step 4 — log the source touchpoint\n\nPer imported contact, log what brought them in:\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"type\": \"note\",\n \"subject\": \"Source: Spring 2026 webinar\",\n \"body\": \"Attended the April 24 product demo. Asked about EU data residency.\",\n \"contactId\": \"\",\n \"metadata\": { \"leadSource\": \"spring-webinar\" }\n }\n}\n```\n\nSetting `contactId` bumps the contact's `lastContactedAt` automatically — the timeline UI uses this for sorting. Activity types: `note | call | email | meeting | task`.\n\n## Step 5 — AI summary for prioritization\n\n```jsonc\n{\n \"name\": \"crm_set_ai_summary\",\n \"arguments\": {\n \"entityType\": \"contact\",\n \"id\": \"\",\n \"summary\": \"Head of Ops at a 200-person fintech. Engaged on data-residency questions during the webinar — buyer signal. Likely budget cycle Q3.\",\n \"nextAction\": \"Send EU data-residency one-pager + propose a 30-min discovery call.\"\n }\n}\n```\n\nBoth `summary` and `nextAction` are free-text. `null` clears the field. Also set on the deal if you created one (`entityType: \"deal\"`).\n\n## What NOT to do\n\n- **Don't bulk-create without a tag.** Tag the import (`tags: [\"webinar-2026-04\"]`) so it's trivial to find or revert in `crm_list_contacts({ tag: \"...\" })` if the source turns out to be junk.\n- **Don't skip the AI summary step.** Downstream prioritization (and humans skimming the list) read it. An empty summary is a buried lead.\n- **Don't paste raw CSV into `customFields`.** The map is freeform but lives in the contact row forever — only what the team will actually filter on belongs there.\n- **Don't import without checking compliance.** GDPR / opt-in: confirm the source had explicit consent before logging the touchpoint as `email` or `call`.\n\n## Related\n\n- `skill://crm/onboard-new-customer` — slower path for a single named customer.\n- `skill://crm/deduplicate-contacts` — what to do when a list has overlap with existing contacts.\n- `skill://crm/progress-deal-through-pipeline` — moving the new deals through stages.\n- `skill://playbooks/customer-acquisition` — end-to-end CRM + Conv flow that starts from this import.\n" }, { "uri": "skill://crm/onboard-new-customer", @@ -240,7 +240,7 @@ "title": "CRM: Onboard a new customer", "description": "Recommended sequence for capturing a new customer, attaching them to a company, and seeding the AI summary that drives later prioritization.", "mimeType": "text/markdown", - "content": "# Onboard a new customer\n## TL;DR sequence\n\n1. `crm_find_contact` by email — avoid duplicates.\n2. If miss: `crm_create_contact` with email + name + as much structured detail as you have.\n3. Optionally: `crm_create_company` and link via `crm_update_contact` `companyId`.\n4. `crm_log_activity` for the source touchpoint (signup, demo, inbound email, etc.).\n5. `crm_set_ai_summary` with a 2–4 sentence summary of who they are and why they matter — this powers downstream prioritization.\n\n## Step 1 — dedupe\n\nAlways check first:\n\n```jsonc\n{ \"name\": \"crm_find_contact\", \"arguments\": { \"email\": \"vita@acme.com\" } }\n```\n\nReturns `null` if no match, or the contact DTO. **Do not** call `crm_create_contact` without checking — duplicate emails are allowed at the schema level (so two real people at the same shared inbox can coexist) but consolidating fragments of one human across rows is painful later.\n\n## Step 2 — create\n\n```jsonc\n{\n \"name\": \"crm_create_contact\",\n \"arguments\": {\n \"email\": \"vita@acme.com\",\n \"name\": \"Vita Soto\",\n \"title\": \"Head of Ops\",\n \"phone\": \"+1-555-0100\",\n \"metadata\": { \"source\": \"signup\", \"utm_campaign\": \"spring-launch\" }\n }\n}\n```\n\nEmail + name are the two fields that make the contact useful. Title and phone are nice-to-have. `metadata` is a free-form jsonb bucket — use it for source attribution, lifecycle stage, anything else that doesn't fit a column.\n\n## Step 3 — company linkage (when relevant)\n\nFor B2B flows, attach the contact to a company so account-level reporting works:\n\n```jsonc\n{ \"name\": \"crm_create_company\", \"arguments\": { \"name\": \"Acme Corp\", \"domain\": \"acme.com\" } }\n```\n\nThen:\n\n```jsonc\n{ \"name\": \"crm_update_contact\", \"arguments\": { \"id\": \"\", \"companyId\": \"\" } }\n```\n\nIf a company with the same `domain` already exists, the create call returns it instead of erroring — safe to call repeatedly.\n\n## Step 4 — log the touchpoint\n\n`crm_log_activity` is your main \"what happened\" event store:\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"contactId\": \"\",\n \"type\": \"signup\",\n \"summary\": \"Signed up via the Spring Launch landing page; selected Pro plan.\",\n \"metadata\": { \"plan\": \"pro\", \"trial_days\": 14 }\n }\n}\n```\n\n## Step 5 — seed the AI summary\n\nThis is the highest-leverage call. The summary surfaces in conversation views, on the contact card, and feeds reports:\n\n```jsonc\n{\n \"name\": \"crm_set_ai_summary\",\n \"arguments\": {\n \"contactId\": \"\",\n \"summary\": \"Vita runs ops at Acme Corp (50-person logistics SaaS). Just signed up for Pro after attending our spring webinar. Cited integration with their ticketing system as the deciding factor.\"\n }\n}\n```\n\nKeep it factual, short, and dense with names + numbers. Avoid platitudes (\"interested in solutions\") — the summary is what other agents read first when triaging this contact.\n\n## Idempotency notes\n\n- `crm_create_contact` with a duplicate email creates a second row. Always `crm_find_contact` first.\n- `crm_create_company` with a duplicate `domain` returns the existing company.\n- `crm_log_activity` is fire-and-forget — duplicate logs are noise but not corruption.\n- `crm_set_ai_summary` overwrites; multiple calls is fine.\n\n## What good looks like\n\nA well-onboarded contact has: email, name, title, company link, one activity row capturing how they arrived, and an AI summary you'd be happy to read in 6 months.\n" + "content": "# Onboard a new customer\n## TL;DR sequence\n\n1. `crm_lookup_contact` by email — avoid duplicates.\n2. If miss: `crm_create_contact` with email + name + as much structured detail as you have.\n3. Optionally: `crm_create_company` and link via `crm_update_contact` `companyId`.\n4. `crm_log_activity` for the source touchpoint (signup, demo, inbound email, etc.).\n5. `crm_set_ai_summary` with a 2–4 sentence summary of who they are and why they matter — this powers downstream prioritization.\n\n## Step 1 — dedupe\n\nAlways check first:\n\n```jsonc\n{ \"name\": \"crm_lookup_contact\", \"arguments\": { \"email\": \"vita@acme.com\" } }\n```\n\nReturns `null` if no match, or the contact DTO. **Do not** call `crm_create_contact` without checking — duplicate emails are allowed at the schema level (so two real people at the same shared inbox can coexist) but consolidating fragments of one human across rows is painful later.\n\n## Step 2 — create\n\n```jsonc\n{\n \"name\": \"crm_create_contact\",\n \"arguments\": {\n \"email\": \"vita@acme.com\",\n \"name\": \"Vita Soto\",\n \"title\": \"Head of Ops\",\n \"phone\": \"+1-555-0100\",\n \"metadata\": { \"source\": \"signup\", \"utm_campaign\": \"spring-launch\" }\n }\n}\n```\n\nEmail + name are the two fields that make the contact useful. Title and phone are nice-to-have. `metadata` is a free-form jsonb bucket — use it for source attribution, lifecycle stage, anything else that doesn't fit a column.\n\n## Step 3 — company linkage (when relevant)\n\nFor B2B flows, attach the contact to a company so account-level reporting works:\n\n```jsonc\n{ \"name\": \"crm_create_company\", \"arguments\": { \"name\": \"Acme Corp\", \"domain\": \"acme.com\" } }\n```\n\nThen:\n\n```jsonc\n{ \"name\": \"crm_update_contact\", \"arguments\": { \"id\": \"\", \"companyId\": \"\" } }\n```\n\nIf a company with the same `domain` already exists, the create call returns it instead of erroring — safe to call repeatedly.\n\n## Step 4 — log the touchpoint\n\n`crm_log_activity` is your main \"what happened\" event store:\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"contactId\": \"\",\n \"type\": \"signup\",\n \"summary\": \"Signed up via the Spring Launch landing page; selected Pro plan.\",\n \"metadata\": { \"plan\": \"pro\", \"trial_days\": 14 }\n }\n}\n```\n\n## Step 5 — seed the AI summary\n\nThis is the highest-leverage call. The summary surfaces in conversation views, on the contact card, and feeds reports:\n\n```jsonc\n{\n \"name\": \"crm_set_ai_summary\",\n \"arguments\": {\n \"contactId\": \"\",\n \"summary\": \"Vita runs ops at Acme Corp (50-person logistics SaaS). Just signed up for Pro after attending our spring webinar. Cited integration with their ticketing system as the deciding factor.\"\n }\n}\n```\n\nKeep it factual, short, and dense with names + numbers. Avoid platitudes (\"interested in solutions\") — the summary is what other agents read first when triaging this contact.\n\n## Idempotency notes\n\n- `crm_create_contact` with a duplicate email creates a second row. Always `crm_lookup_contact` first.\n- `crm_create_company` with a duplicate `domain` returns the existing company.\n- `crm_log_activity` is fire-and-forget — duplicate logs are noise but not corruption.\n- `crm_set_ai_summary` overwrites; multiple calls is fine.\n\n## What good looks like\n\nA well-onboarded contact has: email, name, title, company link, one activity row capturing how they arrived, and an AI summary you'd be happy to read in 6 months.\n" }, { "uri": "skill://crm/progress-deal-through-pipeline", @@ -249,7 +249,7 @@ "title": "CRM: Progress a deal through the pipeline", "description": "Move a deal through pipeline stages with the right activity log at each gate, then refresh AI summary so prioritization stays fresh.", "mimeType": "text/markdown", - "content": "# Progress a deal through the pipeline\nA deal lives in one stage of one pipeline. Pipelines are per-org, stages are ordered, and terminal stages are flagged `winLoss: 'won' | 'lost'`. Moving to a terminal stage **auto-stamps `closedAt`** — there's no \"close the deal\" tool separate from `crm_change_stage`.\n\n## TL;DR\n\n1. `crm_list_pipelines` — confirm stage layout.\n2. `crm_list_deals` — find deals to advance.\n3. For each: `crm_list_activities(dealId)` to verify gate criteria are met.\n4. `crm_change_stage` to advance. If the destination is `won`/`lost`, the deal is closed.\n5. `crm_log_activity` recording why the deal moved.\n6. `crm_set_ai_summary` on the deal so the next person looking at it has fresh context.\n\n## Step 1 — read the pipeline\n\n```jsonc\n{ \"name\": \"crm_list_pipelines\", \"arguments\": {} }\n```\n\nReturns each pipeline with `stages: [{ id, name, position, winLoss }, ...]`. Stages are returned in `position` order. Note which stage ids are terminal — those auto-close.\n\n## Step 2 — find candidate deals\n\n```jsonc\n{\n \"name\": \"crm_list_deals\",\n \"arguments\": { \"pipelineId\": \"\", \"stageId\": \"\", \"limit\": 200 }\n}\n```\n\nFiltering by `stageId` lets you address one bottleneck at a time (e.g. all deals stuck in \"Discovery\" for >30 days).\n\n## Step 3 — verify gate criteria\n\nEach stage transition has implicit criteria. Use the activity log to validate:\n\n```jsonc\n{ \"name\": \"crm_list_activities\", \"arguments\": { \"dealId\": \"\", \"limit\": 50 } }\n```\n\nExamples:\n- **Discovery → Proposal**: at least one `meeting` activity logged.\n- **Proposal → Negotiation**: at least one `email` activity sent within the last 14 days; `expectedCloseAt` set.\n- **Negotiation → Closed Won**: a `note` activity from a sales human approving the move; deal `amountCents` non-zero.\n\nThese gates are conventions, not enforced by the platform. If the operator's policy is documented elsewhere, follow it; otherwise prompt before advancing.\n\n## Step 4 — advance the stage\n\n```jsonc\n{\n \"name\": \"crm_change_stage\",\n \"arguments\": { \"dealId\": \"\", \"stageId\": \"\" }\n}\n```\n\nIf the target stage has `winLoss: 'won'` or `'lost'`, `closedAt` is stamped automatically. There is **no `crm_close_deal` tool** — closure is implicit in the terminal-stage transition.\n\n## Step 5 — log why\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"type\": \"note\",\n \"subject\": \"Advanced to Negotiation\",\n \"body\": \"Customer signed off on the EU data-residency clause. Reviewing pricing on a follow-up call next Tuesday.\",\n \"dealId\": \"\",\n \"metadata\": { \"fromStage\": \"\", \"toStage\": \"\" }\n }\n}\n```\n\nActivity types: `note | call | email | meeting | task`. Use `task` with a `dueAt` if there's a follow-up commitment (\"send revised quote by Friday\").\n\n## Step 6 — refresh the AI summary\n\nThe AI summary is the field that drives \"what's hot\" in the dashboard. Don't leave it stale after a stage move:\n\n```jsonc\n{\n \"name\": \"crm_set_ai_summary\",\n \"arguments\": {\n \"entityType\": \"deal\",\n \"id\": \"\",\n \"summary\": \"$50k TCV, 200-seat fintech. Cleared legal review (EU residency). Pricing call set for May 5. Champion: Vita (Head of Ops).\",\n \"nextAction\": \"Send revised quote with annual discount tier; confirm signing authority.\"\n }\n}\n```\n\nSetting `summary` updates `lastAiTouchAt` and `aiSummaryAt` automatically.\n\n## Closed deals\n\nWhen the stage move closes the deal:\n- The deal still appears in `crm_list_deals` — it's not soft-deleted, just `closedAt`-stamped.\n- AI summary should reflect the outcome (\"Won — landed at $48k ACV. Decision driven by EU residency.\"). This is what the next account-management person reads.\n- For \"lost\" deals, `nextAction` is a great place to record the gap (\"Lost on price; revisit in Q3 if our usage-based plan ships.\").\n\n## What NOT to do\n\n- **Don't skip stages.** `crm_change_stage` accepts any `stageId` in the same pipeline, including jumps. The platform allows it; the audit trail makes it look like work was skipped. If you must jump, log a `note` explaining why.\n- **Don't move a deal to \"Closed Won\" without confirming `amountCents` is set.** Pipeline reporting (and the AI summary on the company) treats won deals with $0 as a data problem.\n- **Don't reuse `crm_change_stage` to \"reopen\" a closed deal.** It works (the platform stamps `closedAt` on terminal moves but doesn't clear it on reverse moves), but the deal will appear closed-but-active and reports get confused. Create a new deal instead with `metadata.relatedDealId`.\n\n## Related\n\n- `skill://crm/import-and-score-leads` — how the deals got into the pipeline in the first place.\n- `skill://crm/onboard-new-customer` — the dedup + create flow before any pipeline work.\n" + "content": "# Progress a deal through the pipeline\nA deal lives in one stage of one pipeline. Pipelines are per-org, stages are ordered, and terminal stages are flagged `winLoss: 'won' | 'lost'`. Moving to a terminal stage **auto-stamps `closedAt`** — there's no \"close the deal\" tool separate from `crm_change_deal_stage`.\n\n## TL;DR\n\n1. `crm_list_pipelines` — confirm stage layout.\n2. `crm_list_deals` — find deals to advance.\n3. For each: `crm_list_activities(dealId)` to verify gate criteria are met.\n4. `crm_change_deal_stage` to advance. If the destination is `won`/`lost`, the deal is closed.\n5. `crm_log_activity` recording why the deal moved.\n6. `crm_set_ai_summary` on the deal so the next person looking at it has fresh context.\n\n## Step 1 — read the pipeline\n\n```jsonc\n{ \"name\": \"crm_list_pipelines\", \"arguments\": {} }\n```\n\nReturns each pipeline with `stages: [{ id, name, position, winLoss }, ...]`. Stages are returned in `position` order. Note which stage ids are terminal — those auto-close.\n\n## Step 2 — find candidate deals\n\n```jsonc\n{\n \"name\": \"crm_list_deals\",\n \"arguments\": { \"pipelineId\": \"\", \"stageId\": \"\", \"limit\": 200 }\n}\n```\n\nFiltering by `stageId` lets you address one bottleneck at a time (e.g. all deals stuck in \"Discovery\" for >30 days).\n\n## Step 3 — verify gate criteria\n\nEach stage transition has implicit criteria. Use the activity log to validate:\n\n```jsonc\n{ \"name\": \"crm_list_activities\", \"arguments\": { \"dealId\": \"\", \"limit\": 50 } }\n```\n\nExamples:\n- **Discovery → Proposal**: at least one `meeting` activity logged.\n- **Proposal → Negotiation**: at least one `email` activity sent within the last 14 days; `expectedCloseAt` set.\n- **Negotiation → Closed Won**: a `note` activity from a sales human approving the move; deal `amountCents` non-zero.\n\nThese gates are conventions, not enforced by the platform. If the operator's policy is documented elsewhere, follow it; otherwise prompt before advancing.\n\n## Step 4 — advance the stage\n\n```jsonc\n{\n \"name\": \"crm_change_deal_stage\",\n \"arguments\": { \"dealId\": \"\", \"stageId\": \"\" }\n}\n```\n\nIf the target stage has `winLoss: 'won'` or `'lost'`, `closedAt` is stamped automatically. There is **no `crm_close_deal` tool** — closure is implicit in the terminal-stage transition.\n\n## Step 5 — log why\n\n```jsonc\n{\n \"name\": \"crm_log_activity\",\n \"arguments\": {\n \"type\": \"note\",\n \"subject\": \"Advanced to Negotiation\",\n \"body\": \"Customer signed off on the EU data-residency clause. Reviewing pricing on a follow-up call next Tuesday.\",\n \"dealId\": \"\",\n \"metadata\": { \"fromStage\": \"\", \"toStage\": \"\" }\n }\n}\n```\n\nActivity types: `note | call | email | meeting | task`. Use `task` with a `dueAt` if there's a follow-up commitment (\"send revised quote by Friday\").\n\n## Step 6 — refresh the AI summary\n\nThe AI summary is the field that drives \"what's hot\" in the dashboard. Don't leave it stale after a stage move:\n\n```jsonc\n{\n \"name\": \"crm_set_ai_summary\",\n \"arguments\": {\n \"entityType\": \"deal\",\n \"id\": \"\",\n \"summary\": \"$50k TCV, 200-seat fintech. Cleared legal review (EU residency). Pricing call set for May 5. Champion: Vita (Head of Ops).\",\n \"nextAction\": \"Send revised quote with annual discount tier; confirm signing authority.\"\n }\n}\n```\n\nSetting `summary` updates `lastAiTouchAt` and `aiSummaryAt` automatically.\n\n## Closed deals\n\nWhen the stage move closes the deal:\n- The deal still appears in `crm_list_deals` — it's not soft-deleted, just `closedAt`-stamped.\n- AI summary should reflect the outcome (\"Won — landed at $48k ACV. Decision driven by EU residency.\"). This is what the next account-management person reads.\n- For \"lost\" deals, `nextAction` is a great place to record the gap (\"Lost on price; revisit in Q3 if our usage-based plan ships.\").\n\n## What NOT to do\n\n- **Don't skip stages.** `crm_change_deal_stage` accepts any `stageId` in the same pipeline, including jumps. The platform allows it; the audit trail makes it look like work was skipped. If you must jump, log a `note` explaining why.\n- **Don't move a deal to \"Closed Won\" without confirming `amountCents` is set.** Pipeline reporting (and the AI summary on the company) treats won deals with $0 as a data problem.\n- **Don't reuse `crm_change_deal_stage` to \"reopen\" a closed deal.** It works (the platform stamps `closedAt` on terminal moves but doesn't clear it on reverse moves), but the deal will appear closed-but-active and reports get confused. Create a new deal instead with `metadata.relatedDealId`.\n\n## Related\n\n- `skill://crm/import-and-score-leads` — how the deals got into the pipeline in the first place.\n- `skill://crm/onboard-new-customer` — the dedup + create flow before any pipeline work.\n" }, { "uri": "skill://kb/create-first-space", @@ -303,7 +303,7 @@ "title": "Outreach: Draft an initial call", "description": "Draft the opening and talking points for a first-touch outbound call on a voice campaign. One pending proposal per (campaign, contact). Only a signed-in person in the Munin dashboard can place the call — an agent never can, on any host.", "mimeType": "text/markdown", - "content": "# Draft an initial call\n\nSame pass as `skill://outreach/draft-initial-email`, for a campaign running on a voice channel. What you write is not a message that gets delivered — it is the **opening and talking points an AI voice agent speaks** when the call connects. Everything after the first few seconds is a live conversation you don't control.\n\n**You cannot place the call.** `outreach_approve_proposal` refuses every caller that is not a signed-in dashboard user: agents, admin API keys, the Slack button. That is the safety floor for outbound calling and there is no argument, tool or credential that gets around it. Draft, file the proposal, tell the operator it is waiting in the dashboard inbox, and stop. `outreach_revise_proposal`, `outreach_withdraw_proposal` and `outreach_dismiss_proposal` all work — none of them dial.\n\nTake the asymmetry seriously. A cold email is ignorable and a text is cheap; an unsolicited AI phone call interrupts someone, cannot be un-rung, and in most of Europe is the most heavily regulated thing Munin can do. Draft fewer, better calls than you would emails.\n\n## The pass\n\n1. **List campaigns** with `outreach_list_campaigns` and keep the enabled ones. Confirm the campaign is on a voice channel before writing spoken copy — an existing proposal shows `delivery.channelType`.\n2. **Materialise the audience** with `crm_list_contacts_in_segment(campaign.segmentId)`. Already filtered for suppression and lawful basis.\n3. **Skip contacts with no `phone`** — `outreach_propose_initial` rejects them.\n4. **Be stricter than the segment.** The segment says you *may* contact them. Calling asks whether you *should*: a call needs a reason this specific person would welcome one — they asked to be called, they started something and stopped, they are mid-deal. \"They match the filter\" is a reason to email, not to phone. Skip the rest and say why in `evidence`.\n5. **Dedupe** via `outreach_list_proposals({ kind: \"initial\", campaignId, contactId })` as the email pass does.\n6. **File** with `outreach_propose_initial({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a call has no subject, and passing one is rejected.\n7. **Stop.**\n\n## Writing for a voice agent\n\n- **Write speech, not prose.** It will be read aloud by a text-to-speech voice. Short sentences. No markdown, no bullet characters, no headings, no emoji — they are either spoken literally or mangled.\n- **No URLs, no email addresses, no reference codes.** Nobody can click a link on a phone call, and a spelled-out address is painful. If there is something to send, the point of the call is to earn permission to send it.\n- **Say who is calling, from where, and why, in the first sentence.** A silent or meandering opening is how an AI call gets hung up on, and it is what a recipient's complaint will quote.\n- **Give the agent a goal and boundaries, not a script to recite.** It handles the conversation; you set the intent. Say what to do if the person is busy — offer to call back, don't push.\n- **Say what the agent must not do**: don't claim to be human if asked, don't quote prices or commitments you haven't given it, don't keep someone who says no.\n- **Use the recipient's language.** A Norwegian number gets Norwegian.\n\nGood:\n\n`Open: \"Hei, dette er Munin-assistenten som ringer på vegne av Kjell hos Munin. Du ba om en oppringing da du testet onboarding-flyten — passer det å snakke i to minutter?\" If busy: offer to call back and end the call. Goal: book a 20-minute demo, propose Tuesday or Thursday morning. If asked whether this is a real person: say plainly it is an AI assistant. Don't discuss pricing — say Kjell will follow up by email.`\n\nBad:\n\n`Hi! 👋 We wanted to reach out about **our new onboarding flow** — check it out at https://getmunin.com/onboarding and book a slot!` — emoji and markdown read aloud, a URL nobody can use, no identification, no goal, no boundaries.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin hands the campaign's voice channel the destination number and your draft as the assistant's opening context — every supported voice vendor works the same way here, so write for the channel, not for a particular provider — then creates a stub conversation linked to the campaign so the transcript lands somewhere. Approval is refused — before dialling — if the campaign is inside its `cadenceRules` quiet hours or on a blackout date. Quiet hours are read in the campaign's `quietHoursTimezone`, so set one; without it they are read in UTC, which is not what \"no calls before 08:00\" means anywhere in Europe.\n\nFollow-up sequences are not available on voice campaigns. One call, then whatever the conversation becomes.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass where you may write at length.\n- `skill://outreach/draft-initial-sms` — the short-form written equivalent.\n- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.\n- `skill://conv/setup-voice-sms-channel` — configuring the voice channel the campaign runs on.\n" + "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_message` 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_message({ campaignId, contactId, draftBody, evidence })`. No `draftSubject` — a call has no subject, and passing one is rejected.\n7. **Stop.**\n\n## Writing for a voice agent\n\n- **Write speech, not prose.** It will be read aloud by a text-to-speech voice. Short sentences. No markdown, no bullet characters, no headings, no emoji — they are either spoken literally or mangled.\n- **No URLs, no email addresses, no reference codes.** Nobody can click a link on a phone call, and a spelled-out address is painful. If there is something to send, the point of the call is to earn permission to send it.\n- **Say who is calling, from where, and why, in the first sentence.** A silent or meandering opening is how an AI call gets hung up on, and it is what a recipient's complaint will quote.\n- **Give the agent a goal and boundaries, not a script to recite.** It handles the conversation; you set the intent. Say what to do if the person is busy — offer to call back, don't push.\n- **Say what the agent must not do**: don't claim to be human if asked, don't quote prices or commitments you haven't given it, don't keep someone who says no.\n- **Use the recipient's language.** A Norwegian number gets Norwegian.\n\nGood:\n\n`Open: \"Hei, dette er Munin-assistenten som ringer på vegne av Kjell hos Munin. Du ba om en oppringing da du testet onboarding-flyten — passer det å snakke i to minutter?\" If busy: offer to call back and end the call. Goal: book a 20-minute demo, propose Tuesday or Thursday morning. If asked whether this is a real person: say plainly it is an AI assistant. Don't discuss pricing — say Kjell will follow up by email.`\n\nBad:\n\n`Hi! 👋 We wanted to reach out about **our new onboarding flow** — check it out at https://getmunin.com/onboarding and book a slot!` — emoji and markdown read aloud, a URL nobody can use, no identification, no goal, no boundaries.\n\n## What happens on approval\n\nA person approves in the dashboard. Munin hands the campaign's voice channel the destination number and your draft as the assistant's opening context — every supported voice vendor works the same way here, so write for the channel, not for a particular provider — then creates a stub conversation linked to the campaign so the transcript lands somewhere. Approval is refused — before dialling — if the campaign is inside its `cadenceRules` quiet hours or on a blackout date. Quiet hours are read in the campaign's `quietHoursTimezone`, so set one; without it they are read in UTC, which is not what \"no calls before 08:00\" means anywhere in Europe.\n\nFollow-up sequences are not available on voice campaigns. One call, then whatever the conversation becomes.\n\n## Related\n\n- `skill://outreach/draft-initial-email` — the same pass where you may write at length.\n- `skill://outreach/draft-initial-sms` — the short-form written equivalent.\n- `skill://outreach/review-proposals` — what the operator does with what you filed, and why you cannot do it for them.\n- `skill://conv/setup-voice-sms-channel` — configuring the voice channel the campaign runs on.\n" }, { "uri": "skill://outreach/draft-initial-email", @@ -312,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-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" + "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_message({ 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_message\",\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_message` 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", @@ -321,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/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" + "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_message` 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_message({ 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", @@ -366,7 +366,7 @@ "title": "Playbook: Frontend integration (Conv widget + Analytics + CMS)", "description": "Wire a freshly-scaffolded frontend (Lovable, Bolt, Replit, v0, Cursor, Claude Code, …) to a Munin tenant — chat widget, page-view tracker, and live CMS content — without rediscovering the same five gotchas every time.", "mimeType": "text/markdown", - "content": "# Frontend integration (Conv widget + Analytics + CMS)\n\nYou're a coding agent setting up a frontend that talks to an existing Munin tenant: chat widget bubble, page-view tracker, and live blog/article content from the CMS. Every Munin-shaped surface in the page collapses into one of three integrations:\n\n- **Chat widget** — drop-in `\n```\n\nAll three `data-*` attributes are required. The script tag's `src` and the `data-munin-host` value should normally be the same origin.\n\nOptional attributes (greeting text, theme color, identified-user HMAC, visitor metadata) are documented in `skill://conv/setup-chat-widget`. Don't add them unless the operator asked.\n\n### 1c. SPA route changes\n\nThe widget itself doesn't care about route changes — it's a fixed-position bubble that overlays the page. Nothing extra to wire.\n\n## Step 2 — analytics tracker\n\n### 2a. Mint a tracker key\n\n```jsonc\n{\n \"name\": \"analytics_create_tracker\",\n \"arguments\": {\n \"name\": \"\",\n \"allowedOrigins\": [\n \"https://abc123.lovable.app\",\n \"https://app.customer.example\"\n ]\n }\n}\n```\n\nResponse includes `trackerKey: \"mn_track_…\"` (shown once — capture it). Same origin allowlisting story as the widget: empty `allowedOrigins` accepts any origin by default; setting `MUNIN_TRACKER_REQUIRE_ALLOWLIST=1` on the backend makes it fail closed instead. Cloud production should run with the env var on; OSS dev is open-by-default for ergonomics.\n\n### 2b. Embed the script\n\n```tsx\nconst API_URL = import.meta.env.VITE_API_URL;\n\n\n```\n\nAll three `data-*` attributes are required. The script tag's `src` and the `data-munin-host` value should normally be the same origin.\n\nOptional attributes (greeting text, theme color, identified-user HMAC, visitor metadata) are documented in `skill://conv/setup-chat-widget`. Don't add them unless the operator asked.\n\n### 1c. SPA route changes\n\nThe widget itself doesn't care about route changes — it's a fixed-position bubble that overlays the page. Nothing extra to wire.\n\n## Step 2 — analytics tracker\n\n### 2a. Mint a tracker key\n\n```jsonc\n{\n \"name\": \"analytics_create_tracker\",\n \"arguments\": {\n \"name\": \"\",\n \"allowedOrigins\": [\n \"https://abc123.lovable.app\",\n \"https://app.customer.example\"\n ]\n }\n}\n```\n\nResponse includes `trackerKey: \"mn_track_…\"` (shown once — capture it). Same origin allowlisting story as the widget: empty `allowedOrigins` accepts any origin by default; setting `MUNIN_TRACKER_REQUIRE_ALLOWLIST=1` on the backend makes it fail closed instead. Cloud production should run with the env var on; OSS dev is open-by-default for ergonomics.\n\n### 2b. Embed the script\n\n```tsx\nconst API_URL = import.meta.env.VITE_API_URL;\n\n