Hogsend is brand new.Chat to Doug
Recipes — Human-in-the-loop

Human-in-the-loop

Flows that pause for a person — approvals, lead alerts, concierge touches.

These recipes pause for a person. The journey waits on an event that an operator fires — an approval, a reply, a hand-raise — so the flow only continues once a human has weighed in.

Every recipe below is the working code — copy it straight in, or open the full write-up for the wiring and the reasoning.

4 recipes

The recipes

Lead alerts

A confirmed hand-raise pages a human — outside the journey's exit rules and the lead's preferences.

The flag fires even when the lead has unsubscribed — an explicit hand-raise reaches the operator, with the subscription state recorded in the properties instead of gating the trigger.

Full write-up
src/journeys/setup-offer.ts
export const setupOffer = defineJourney({
  meta: {
    id: "setup-offer",
    name: "Human-in-the-loop — setup offer",
    enabled: true,
    trigger: { event: Events.TRIAL_STARTED },
    entryLimit: "once",
    suppress: hours(12),
    // Converting withdraws the pitch, even mid-wait. The awaited answer
    // event (offer.answered) must NEVER appear here.
    exitOn: [{ event: Events.SUBSCRIPTION_CREATED }],
  },

  run: async (user, ctx) => {
    await ctx.sleep({ duration: days(1), label: "pre-offer" });
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.OFFER_SETUP_CALL,
      subject: "Want a hand getting set up?",
      journeyName: user.journeyName,
    });

    // Answers are provisional clicks confirmed ~30s later; lookback covers
    // the gap between the send and this wait being established.
    const answer = await ctx.waitForEvent({
      event: Events.OFFER_ANSWERED,
      timeout: days(4),
      lookback: minutes(30),
    });

    // Gates sends to the USER — the internal flag records the value
    // instead of being gated by it.
    const subscribed = await ctx.guard.isSubscribed();

    if (answer.timedOut || answer.properties?.answer !== "interested") {
      return; // silence or "not now" — the no is respected
    }

    // Scalars only: the lead's email and name are resolved server-side by
    // the notify-lead task. Never put PII in event properties.
    await ctx.trigger({
      event: Events.LEAD_FLAGGED,
      userId: user.id,
      properties: {
        reason: "setup-offer",
        answer: "interested",
        sourceEvent: Events.OFFER_ANSWERED,
        answeredAt: new Date().toISOString(),
        subscribed,
      },
    });
  },
});

Human approval gate

Park the journey on a durable wait until an operator approves with one event — silence fails safe.

ctx.trigger asks, ctx.waitForEvent parks for up to two days, and the timeout path sends the pre-approved fallback — the custom discount structurally cannot go out unapproved.

Full write-up
src/journeys/human-approval-gate.ts
export const humanApprovalGate = defineJourney({
  meta: {
    id: "human-approval-gate",
    name: "Win-back — approval-gated discount",
    enabled: true,
    trigger: {
      event: Events.ACCOUNT_AT_RISK,
      // only high-value accounts justify a custom offer (and a human's time)
      where: (b) => b.prop("mrr").gte(500),
    },
    entryLimit: "once_per_period",
    entryPeriod: days(90),
    suppress: hours(24),
    // goal met — they reactivated on their own. NEVER list the awaited
    // approval.granted event here.
    exitOn: [{ event: Events.SUBSCRIPTION_REACTIVATED }],
  },

  run: async (user, ctx) => {
    const requestedAt = new Date().toISOString();

    // Ask a human. A real ingested event — the request-approval Hatchet
    // task picks it up (onEvents) and emails the approver.
    await ctx.trigger({
      event: Events.APPROVAL_REQUESTED,
      userId: user.id,
      properties: {
        action: "winback-discount",
        discountPct: 30,
        mrr: Number(user.properties.mrr ?? 0),
        requestedAt,
      },
    });

    // Park here until the approver fires approval.granted for THIS user —
    // or two days pass, whichever comes first.
    const approval = await ctx.waitForEvent({
      event: Events.APPROVAL_GRANTED,
      timeout: days(2),
      label: "await-approval",
    });

    if (!(await ctx.guard.isSubscribed())) return;

    if (approval.timedOut) {
      // Fail safe: silence means the standard, pre-approved offer.
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.WINBACK_STANDARD,
        subject: "Before you go — a quick look at what's new",
        journeyName: user.journeyName,
      });
      return;
    }

    // The approver's payload can adjust the terms — validate it, these are
    // best-effort scalars, not trusted input.
    const discountPct = Math.min(
      Number(approval.properties?.discountPct ?? 30),
      50,
    );

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.WINBACK_DISCOUNT_OFFER,
      subject: `A ${discountPct}% offer to stay`,
      journeyName: user.journeyName,
      props: { discountPct },
    });
  },
});

Concierge onboarding

Page a CSM on enterprise signup, park until they confirm contact, and fall back so the customer never stalls.

Two durable waits with an escalating re-page between them; a meeting.booked at any point — including mid-wait — exits the run with no further sends.

Full write-up
src/journeys/concierge-onboarding.ts
export const conciergeOnboarding = defineJourney({
  meta: {
    id: "concierge-onboarding",
    name: "Onboarding — enterprise concierge",
    enabled: true,
    trigger: {
      event: Events.USER_SIGNED_UP,
      // self-serve plans take the automated welcome journey instead
      where: (b) => b.prop("plan").eq("enterprise"),
    },
    entryLimit: "once",
    suppress: hours(12),
    // a booked meeting means onboarding is in human hands — stop all of
    // this. The awaited csm.contacted event must NEVER appear here.
    exitOn: [{ event: Events.MEETING_BOOKED }],
  },

  run: async (user, ctx) => {
    const seats = Number(user.properties.seats ?? 0);

    // Page a human. The csm-alert task (onEvents) emails the CSM inbox.
    await ctx.trigger({
      event: Events.CSM_ASSIGNMENT_REQUESTED,
      userId: user.id,
      properties: {
        plan: "enterprise",
        seats,
        requestedAt: new Date().toISOString(),
      },
    });

    // Park until the CSM marks contact — one event from their tool or CRM.
    let contact = await ctx.waitForEvent({
      event: Events.CSM_CONTACTED,
      timeout: days(1),
      label: "await-csm",
    });

    if (contact.timedOut) {
      // A day of silence: re-page. The fresh requestedAt gives the alert
      // task a new idempotency key, so the reminder isn't deduped.
      await ctx.trigger({
        event: Events.CSM_ASSIGNMENT_REQUESTED,
        userId: user.id,
        properties: {
          plan: "enterprise",
          seats,
          requestedAt: new Date().toISOString(),
          reminder: true,
        },
      });

      contact = await ctx.waitForEvent({
        event: Events.CSM_CONTACTED,
        timeout: days(1),
        label: "await-csm-2",
        // covers a csm.contacted that landed between the two waits
        lookback: hours(1),
      });
    }

    if (!(await ctx.guard.isSubscribed())) return;

    if (contact.timedOut) {
      // Two days, no human: the customer never waits on an absent CSM.
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.ONBOARDING_ENTERPRISE_WELCOME,
        subject: "Getting your team set up",
        journeyName: user.journeyName,
      });
      return;
    }

    // The CSM's event carries who reached out — straight into the email.
    const csmName = String(
      contact.properties?.csm_name ?? "your account team",
    );

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ONBOARDING_CONCIERGE_INTRO,
      subject: "Next steps for your rollout",
      journeyName: user.journeyName,
      props: { csmName },
    });
  },
});

Support follow-up

A next-morning 'did this fix it?' — yes ends quietly, no fires a reopen event and pages support.

ctx.when lands the ask at 09:00 the customer's time; the wait branches on the answer payload, and exitOn ticket.reopened makes an out-of-band reopen cancel the question entirely.

Full write-up
src/journeys/support-followup.ts
export const supportFollowup = defineJourney({
  meta: {
    id: "support-followup",
    name: "Support — resolution follow-up",
    enabled: true,
    trigger: { event: Events.TICKET_RESOLVED },
    // a heavy support week is one follow-up, not three
    entryLimit: "once_per_period",
    entryPeriod: days(7),
    suppress: hours(12),
    // reopened through normal support channels — the question is moot.
    // The awaited support.followup_answered event must NEVER appear here.
    exitOn: [{ event: Events.TICKET_REOPENED }],
  },

  run: async (user, ctx) => {
    const ticketId = String(user.properties.ticket_id ?? "");

    // Land the question the next morning in the customer's timezone —
    // not thirty seconds after the agent hits "resolve".
    await ctx.sleepUntil(ctx.when.tomorrow().at("09:00"), {
      label: "next-morning",
    });
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.SUPPORT_FOLLOWUP,
      subject: "Did that fix it?",
      journeyName: user.journeyName,
      props: { ticketId },
    });

    // The yes/no buttons are semantic links: the click IS the answer,
    // confirmed ~30s later (scanner bursts suppressed). lookback covers
    // an answer landing between the send and this wait.
    const answer = await ctx.waitForEvent({
      event: Events.SUPPORT_FOLLOWUP_ANSWERED,
      timeout: days(4),
      label: "await-answer",
      lookback: minutes(30),
    });

    if (answer.timedOut) return; // silence — leave them be

    if (answer.properties?.answer === "no") {
      // A real event: the support-alert task pages the queue, destinations
      // receive it, and another journey could trigger on it.
      await ctx.trigger({
        event: Events.SUPPORT_REOPEN_REQUESTED,
        userId: user.id,
        properties: {
          ticket_id: ticketId,
          source: "followup-email",
          answeredAt: new Date().toISOString(),
        },
      });
    }
    // "yes" ends the run — the answer is already in user_events and at
    // every destination for CSAT reporting.
  },
});

Copy a recipe into your app

Paste any recipe straight into your codebase, or scaffold a fresh app with create-hogsend and build from there.

Free to self-host · One scaffold command · No per-contact billing

terminal
pnpm dlx create-hogsend@latest my-app