Minor Changes
-
a5acd6c: Outreach: extract structured outcomes from a finished voice call
A voice campaign can now declare
extractionSchema— up to 12 fields (key,label,type,description, optionaloptionsandtagPrefix) naming what to pull off each call. When a call on that campaign ends,OutreachCallOutcomeSinkenqueuesskill://outreach/extract-call-outcome, which reads the transcript and writes what the prospect actually said into that contact'scustomFields, plus the always-writtencallOutcome,callOutcomeAtandcallOutcomeConversationId.The fields are static per campaign and dynamic across campaigns: the operator picks what to collect, the model fills it in. That keeps values comparable enough to filter and report on without a migration per customer, which a free-form "let the model choose the fields" pass would not.
Two decisions worth recording. The enqueue is an
EventSinkonconversation.voice.call_endedrather than an inline call inConvService.changeStatus, because the sibling identity pass lives there and adding this one would invert the module dependency —OutreachModuleimportsConvModule, not the reverse. And the skill writes tags as the union of existing and new, becausetagson a contact patch replaces the whole array whilecustomFieldsmerges key-wise; sending only the new tags would delete the segment membership that put the prospect on the call list.extractionSchemais rejected on non-voice campaigns rather than silently ignored, mirroring the existing email-only guard onsequenceSteps. The extraction pass owns outcome fields only — identity stays withskill://crm/extract-contact-from-message, which runs on the same close. -
80d6f34: Outreach: extract outcomes from email and SMS replies, not just calls
extractionSchemanow works on every outreach channel. A voice campaign still extracts once when the call ends; an email or SMS campaign extracts each time the prospect replies, so a campaign that asks a qualifying question in writing gets the same structured answer a call does.The skill is renamed
skill://outreach/extract-call-outcome→skill://outreach/extract-outcome, and its reserved keyscallOutcome/callOutcomeAt/callOutcomeConversationIdbecomeoutreachOutcome/outreachOutcomeAt/outreachOutcomeConversationId, withwrong_numberwidening towrong_contact— a reply from the wrong person is the same signal as a wrong number, and none of those names were true of an email thread. Migration 0084 renames both the persistedcurator_jobs.job_urirows and the existingcrm_contacts.custom_fieldskeys.Three things the implementation turns on. Voice transcript turns are inserted as
end_usermessages and emitconversation.message.receivedlike any inbound mail, so the sink keys the reply trigger to email and SMS channels only — otherwise every prospect utterance mid-call would enqueue an extraction job. The reply trigger dedupes per message rather than per conversation, matching the existingoutreach-draft-reply:msg:key, so a multi-turn thread extracts once per answer and later answers supersede earlier ones. And the contact is now resolved from theoutreach_proposalsrow for the conversation instead ofmetadata.crmContactId, which only the voice stub ever wrote.The voice-only guard on
extractionSchemais gone, since every channel a campaign can run on now supports extraction. -
701413c: Scope a dashboard session's organization per request instead of per account
The active organization was a single account-wide flag (
org_members.is_default) that every/v1/*request re-read, while the dashboard read the organization name once at page load and cached it. Switching organizations in one tab therefore changed what every other open tab was served, without changing what those tabs displayed — a stale label sitting on top of another organization's data. The same applied to a second browser or device, so no tab-local mechanism could have fixed it.Session credentials now accept a requested organization.
CredentialResolver.resolveSessionTokentakes an optional organization id, checks it against the caller's memberships, and refuses withOrgAccessDeniedErrorwhen it isn't one — it never quietly serves a different organization instead. The control-plane guard reads that id from anx-munin-orgrequest header on the session-cookie path only, so API keys and OAuth tokens stay bound to the organization they were issued for, and it maps the refusal to a403carryingcode: org_access_denied. Every authenticated response now echoes the organization that served it in anx-munin-orgresponse header (exposed through CORS), and the realtime websocket takes the same id as anorgIdconnect parameter.On the client, the dashboard keeps its organization in
sessionStorage, which is per-tab: a tab pins itself to whichever organization served its first response and stays there, sois_defaultnow only decides where a new tab starts.api()sends the pin, adopts the served organization when it has none, and — if the pin is refused, which is what a user switching accounts in the same tab looks like — drops it and retries once, so recovery is invisible rather than a wall of errors.This is the transport half of the fix. Until the organization also appears in the dashboard URL, server-rendered layouts still read
is_default, so a tab pinned elsewhere can briefly render the account-wide organization name before the client corrects it.
Patch Changes
-
12fe29d: Strip quoted history from inbound email replies that Gmail formats in a Nordic locale.
stripQuotedReplyTextmissed two things that combined to leave a full quoted thread in the
stored message body. First, the only Norwegian attribution pattern assumed a leading "den",
so Gmail's date-first form ("ons. 26. aug. 2026 kl. 14:05 skrev Support s@x.example:")
matched nothing — and Gmail hard-wraps that line at 76 columns, putting the address's closing
>:on its own line, which defeated the end-of-line anchor regardless. Second, the heuristic
fallback scans up from the end of the message over quoted lines, so it bailed immediately when
the sender's client placed their signature below the quote rather than above it.Attribution lines are now matched with their quote markers removed (so a nested
> On … wrote:is recognised too), a wrapped address is rejoined before matching, date-first
Nordic attributions are recognised when the line also carries a time of day, and the trailing
scan retries above a signature block. A quote cut no longer swallows a signature that sat
below the quote — it is re-attached so the signature split still records it — and an
attribution on the very first line no longer reduces the whole message to(no body). -
b0753ee: Card grids on Trackers, Channels and Integrations now share one layout. The settings content column has no max-width, so the Trackers page's two-column grid stretched each card to roughly 600px on a wide viewport — twice the width of a channel or connector card, with a 160px sparkline floating in it.
CardGridandCardGridSkeletonlost thecolumnsprop entirely: every call site rendered three columns except that one, so hardcoding the layout is what keeps the pages from drifting apart again.The two Integrations sections also loaded behind a single full-width
CardSkeletonwhile Trackers and Channels loaded behind aCardGridSkeleton, so the placeholder was a wide bar where the real content is a grid of cards. Both sections now useCardGridSkeletonat the card count they actually render — three for connectors, one for the Slack bridge. -
be82132: Stop the docs pages from widening the viewport on phones.
Three rules in
docs.csslet content push the document past the viewport, which on iOS
Safari shrink-to-fits the whole page rather than scrolling the offending element:.field-blockhad no scroll container, so a REST parameter table (365px min-content)
pushed/docs/restto 410px inside a 390px viewport. It now scrolls on its own axis,
matching how.curl > preand.docs-switcheralready behave.- Inline
<code>had no wrap opportunity, so a single long token — e.g.
window.mn.widget.identify(externalId, userHash)in the chat-widget guide — measured
311px inside a 280px column.overflow-wrap: anywhereapplies to inline code only
(:not(pre) > code), so scrollable code blocks are untouched:white-space: pre
suppresses wrapping there regardless. .docs-attrs dtkeptwhite-space: nowrapin the ≤720px single-column layout. That is
right for short mono keys but not for the prosedts in the
skills-vs-tools-vs-rest guide, whose min-content was a whole 399px sentence.
Verified across 13 viewport widths (320–1024) on all 22 docs routes — 286 page-loads,
documentElement.scrollWidth == clientWidtheverywhere. Desktop rendering is unchanged:
every rule either applies only below a breakpoint or only engages when content would
otherwise overflow.
Published packages
@getmunin/analytics-tracker@5.14.0@getmunin/chat-widget@5.14.0@getmunin/agent-host@5.14.0@getmunin/agent-runtime@5.14.0@getmunin/backend-core@5.14.0@getmunin/core@5.14.0@getmunin/dashboard-pages@5.14.0@getmunin/db@5.14.0@getmunin/docs-pages@5.14.0@getmunin/emails@5.14.0@getmunin/inspector-app@5.14.0@getmunin/mcp-toolkit@5.14.0@getmunin/sdk@5.14.0@getmunin/types@5.14.0@getmunin/ui@5.14.0