Munin
Munin developer portal
Get a key →

Build a journey

A journey is a versioned graph that moves each contact through triggers, waits, branches, emails and actions towards one goal. It sends from one email channel, its senderChannelId; journeys_update_sender points it at another. Its messages still go through the outreach outbox — consent, unsubscribe, send rules and the review inbox — but a journey needs no outreach campaign. One converted from a campaign keeps that link: the campaign stops proposing while the journey runs, and outreach tools cannot reconfigure it.

You draft; a person publishes. Every edit lands in the journey's open draft version. Published versions never change. Nothing reaches a customer until someone signed in to the Munin dashboard publishes the draft, so when the draft validates cleanly, say it is ready for review and stop.

Before you draft

  1. Read journeys_list_node_types. It is the reference for every node, the entry block, exit rules, goal and settings.
  2. Find the signals the journey can use:
    • Product events the org's backend sends: analytics_list_product_event_names. Only names listed there will ever arrive; ask the operator before relying on one that is not.
    • Saved audiences: crm_list_segments. Segment filters can include behaviour, for example "did not trigger teammate.invited within 5 days".
    • Email layouts: outreach_list_email_templates.
  3. Pick the email channel to send from with conv_list_channels (type email). If the operator already runs an outreach campaign with a first touch and follow-up steps for this goal, journeys_create_from_campaign turns it into a draft journey (agent-drafted emails, waits, a reply ends the sequence) to adapt instead of starting from scratch; the campaign keeps sending its own first touches until the journey is published, then the journey takes over. Campaigns no longer run follow-up steps themselves, so a campaign that still lists sequenceSteps needs this conversion to follow anyone up.

Draft it

  1. journeys_create with a name, the email channel as senderChannelId and, if you already know the shape, a whole spec.
  2. Refine with small edits: journeys_add_node, journeys_update_node, journeys_remove_node, and journeys_update_settings for start, entry, exit, goal and settings. Use journeys_replace_draft when restructuring most of the graph.
  3. journeys_validate after each round of edits. Fix every error; read the warnings and fix the ones that are mistakes. Every edit tool takes an optional ifSpecHash — the draft's specHash you based the edit on — and refuses with journeys_draft_changed if a person or another agent changed the draft in the meantime; reload with journeys_get and apply your edit again.

Rules that keep a journey safe and readable:

  • Name a goal. goal.event and goal.windowDays are required. Success is measured as goal conversion, not opens.
  • Pick the approval mode by who writes the message. Fixed text — the same email for everyone, with {{name}} style fields — uses approval: "auto": the person who publishes the version reads it once in the publish review, and after that it sends without a review per person, still under the send rules (consent, unsubscribe, quiet hours, the weekly cap). A message the agent writes per person (draftBy: "agent") uses draft_only, so every draft is filed for review in the outreach inbox; it can switch to auto only after it earns it (30 reviewed drafts in 30 days, at least 90% approved unedited). Use draft_only for fixed text too when the operator wants to read every send — cold prospecting, a sensitive account. A step without approval is draft_only.
  • Marketing text messages go through a person every time. A message node with channel: "sms" sends from the SMS channel in settings.smsChannelId (find it with conv_list_channels), so one journey can mix email and SMS. It carries only content.body (at most 480 characters, {{name|fallback}} allowed, an opt-out line is added to marketing texts). A marketing SMS goes to a signed-in reviewer before it is sent, even when the agent wrote it (draftBy: "agent" with a brief); only class: "transactional" SMS — a booking confirmation, a reminder the contact asked for — may use auto. Use it for short, timely nudges — a reminder the day before a webinar — not as a second copy of every email.
  • Chat messages reach signed-in visitors in the widget. A message node with channel: "chat" writes in the chat widget channel in settings.chatChannelId (a chat channel from conv_list_channels). It only reaches a contact whose end user signed in to the widget with a verified identity; anyone else is skipped with no_chat_identity. It carries only content.body in markdown (at most 2000 characters, {{name|fallback}} allowed); with draftBy: "agent" and a brief the agent writes it per person. An agent-written chat step can be auto only once it has earned it, like an agent-written email. Like email it can be draft_only or, when a person sets it while publishing, auto. All of a person's journey chat messages on one channel share one thread, which shows up in the widget even if they opened it in another session. Quiet hours do not apply; consent, the weekly cap and quiet-while-talking do. The step then waits for the message to be seen: seenTimeout (default P3D) bounds the wait, and onUnseen routes an unseen message elsewhere — typically to an email with the same point — while a seen one continues on next. onReply works as for email.
  • Calls go through a person too. A message node with channel: "voice" files a call proposal on settings.voiceChannelId; content.body is the opening the voice agent uses (at most 1000 characters). The agent may write the opening per person (draftBy: "agent" with a brief), but only a signed-in person approves a call — never auto. After the call the outcome pass writes reached, no_contact, refused or wrong_contact, and onOutcome routes each to a node; onOutcome.timeout (after outcomeTimeout, default 6 hours) and any unmapped outcome continue on next.
  • Mark transactional email honestly. class: "transactional" reaches unsubscribed contacts, so use it only for messages the contact needs to use the product (receipts, password or account notices), never for promotion.
  • Give every wait_for an onTimeout. Without it, contacts whose event never comes simply leave.
  • Use onReply on emails that invite an answer. A reply always pauses the contact's run. With onReply the journey answers it on that path, and outreach drafts no separate reply. Without it, the conversation is flagged for the team in Conversations and the run waits there until the thread closes.
  • Read replies with an intent step. Point the email's onReply at an agent node in decide mode with inputs: ["reply", "thread", "contact"], edges such as interested, question, not_now and stop, and fallback: "question" — the safe path where a person or the support agent answers and the journey waits. Choosing stop also ends every other journey the person is in and unsubscribes them from marketing (transactional messages still reach them), logged as a note on the contact. Agent steps read the thread the person last replied on, whichever channel it was.
  • Start from a conversation topic with an event trigger on conversation.topic_set and where: { "topicSlug": "pricing" }; list topics with conv_list_topics.
  • Follow a company instead of a person. entry.subject: "company" runs the journey once per company rather than once per contact. An event from any of its people — or an event that names the company with companyId — enters it and counts toward its goal and exits, a segment trigger enters each company that has at least one eligible contact in the segment, and journeys_enroll takes a companyId (or a contact whose company it then follows). Branches read the company's fields and what any of its people did; crm.add_tag and crm.remove_tag tag the company, crm.log_activity logs on the company, and crm.update_contact and contact date fields are refused because there is no single person. A message step in a company journey names its recipients — tag, titleContains or both, and max (default 3, at most 10) — and the step files one proposal per chosen person, ordered by who was contacted most recently; each still passes their own consent, weekly cap and quiet hours. The run moves on once every proposal is decided: on next if at least one was sent, ending as dismissed only if all were. Email, SMS and chat work; calls, agent drafts and onUnseen follow one person and are refused.
  • One run at a time, unless nothing is sent. entry.reentry is once_at_a_time by default. concurrent starts one run per triggering event for the same person (at most 20 open), for bookkeeping journeys of actions and agent steps such as tagging every abandoned cart; it needs an event trigger and cannot contain message steps.
  • Test, don't guess. A split with control (the current path) and sampleSize sends everyone to the control once the sample is full; journeys_get_split_results then names a winner only when it beats the control significantly. Set settings.optimiser: "weekly" to let the weekly optimiser (skill://journeys/optimise-journey) propose one tested change at a time as a draft for a person to publish.
  • Ask a person when it matters. An approval step holds the run until a signed-in person approves or rejects it in the dashboard (journeys_list_approvals shows what is waiting); agents cannot decide it. Use it before something costly or sensitive — a discount, a call to a key account — not as a review queue for every email.
  • Reuse journeys. A sub_journey step enters the person into another live journey; with await: true the run continues on onGoal if that journey reached its goal, otherwise on next. Journeys may not call each other in a loop, and nesting stops at three levels.
  • The product's own events can carry consent. POST /v1/events reports what happened in the customer's product. An event identifies a person but creates no contact and records no lawful basis, unless it carries consent (lawfulBasis, source, optional givenAt and evidence) and its key also has crm:write. Then Munin creates the contact from the identity's email if there is none, records the basis unless one is already on record, and only then starts journeys. Ask the operator to have the sign-up sync send it, or a marketing journey on a sign-up event reaches nobody new. skill://playbooks/trial-onboarding shows the request.
  • Purchases are events too. With Shopify order events connected (skill://commerce/connect-order-events), order.created, order.paid, order.fulfilled and order.cancelled arrive as product events on the customer's contact: use them as an entry trigger, in wait_for or as the goal.
  • Bookings are events too. A booking created, changed or cancelled through Munin — bookings_create_booking, bookings_update_booking, bookings_cancel_booking or the guest's own self-service tools in chat and voice — arrives as booking.created, booking.updated or booking.cancelled on the guest's contact, with bookingRef, startsAt (the restaurant's local time), partySize and venue under properties. Bookings made directly in the vendor's system do not arrive. To time a step from the booking, use a wait with untilField: { field: "context.trigger.properties.startsAt", offset: "-P1D" }; a time without an offset is read in the contact's or the journey's timezone. The booking-reminder template wires a reminder the day before, a thank-you after the visit and an exit on cancellation.
  • Loops need a wait. A jump back must pass through a wait, wait_for or message; each node also has a per-contact visit cap (settings.maxVisitsPerNode).
  • Respect quiet time. The org's send rules (outreach_get_send_rules) already hold marketing during their quiet hours and over their weekly cap. Set settings.timezone for the market you are writing to, and settings.quietHours, validDays or frequencyCapPerWeek only to be stricter than the org; a journey cannot loosen them. Text messages and calls, transactional ones included, always wait out a quiet window: settings.quietHours, or 21:00–08:00 in the person's timezone when the journey sets none (00:00-00:00 means no quiet window). settings.agentStepsPerDay (default 1000) caps how many agent decisions, drafts and enrichments a journey asks for per UTC day; past it, steps take their fallback and a system alert says so.

Prove it, then hand it over

  1. journeys_simulate runs the draft over real contacts without sending anything: who would enter or be excluded and why, where each person goes, what every email would do (send, wait for quiet hours or a cool-down, or be skipped because the contact is unsubscribed or undeliverable) and rendered samples. Read its assumptions: waits are assumed to elapse, awaited events not to arrive, and reviewers to approve.
  2. Fix what surprises you, then journeys_request_publish. It refuses a draft with validation errors and returns the diff against the live version, listing every email that would send without review (autoNodes).
  3. Summarise for the operator: who enters and when, each path, every email subject, the goal, the simulation counts and anything you assumed, and give them the reviewUrl link. A signed-in person confirms the publish there; you cannot. Editing the draft afterwards cancels the request, so request again after any change.

People can edit the same draft in the dashboard: they select a step in the graph, change its text, approval and edges, add or remove steps, and see validation errors on the step. Re-read it with journeys_get before a larger change so you build on their edits, and tell them what you changed.

After it goes live, journeys_get_report shows enrollments, exits, goal conversion against the holdout and how each node and email performs. journeys_get_agent_stats shows each agent step's runs, fallback rate, tokens, the edges it chose per day and, for agent-drafted messages, how often reviewers edited or rejected them; journeys_simulate estimates the agent runs and tokens a draft would cost.

To change a live journey, edit as above: the edits open a new draft version, and contacts already in the journey finish on the version they entered, unless you move them: journeys_preview_move_enrollments shows which open runs would move to the new version and where (map renamed steps with nodeMap), and journeys_move_enrollments moves them with the same arguments and the preview's fingerprint. To roll back, journeys_list_versions shows every version with its change summary and publish state, and journeys_restore_version copies an earlier one into the draft, which again needs publishing. journeys_pause holds a live journey without removing anyone, and holds its already approved messages until journeys_resume lets it continue in order; journeys_archive retires it, ending every open run (journey_archived) and withdrawing its unsent messages within a few seconds.

A monitor watches every live journey. When a step's bounce, unsubscribe or failed-step rate goes above settings.guardrails (defaults: bounce 5%, unsubscribe 2%, failed steps 10%, measured once a step has 50 sends or runs within 7 days), it pauses that step, raises a system alert and holds everyone who reaches it; when no single step explains a breach across the whole journey, it pauses the journey. journeys_get_guardrails shows the rates and which steps are paused. Find the cause before journeys_resume_node — a stale segment, a bad address list, a message people object to — and fix the draft first if the copy is the problem. journeys_pause_node holds one step by hand.

If an email template that an auto step uses changes after the version was published, that step stops sending: its emails are held (they stay approved and are retried hourly), nothing goes out with the template nobody reviewed, and one system alert per journey says the journey is holding emails. journeys_get_template_changes lists the changed steps and how many emails are held. A signed-in person re-approves the journey with the new template from its dashboard page, which releases them; you cannot. Tell the operator rather than editing the template back. A template a live, draft or running version still names can't be deleted; outreach_get_email_template lists every step that uses it before anyone edits it.