Hogsend is brand new.Chat to Doug
Use case: community

Community lifecycle that reacts to Discord activity

With the Discord plugin wired in, messages, reactions, joins, and presence arrive as events on the same pipeline as PostHog. Once a member links their email, those signals sit on their contact — so a journey reads a quiet stretch or a help thread the way it reads a signup.

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

The disconnect

Your community runs in Discord and your product data sits in PostHog, and the email tool is wired to neither. Put the Discord identity on the same contact as the product events, and a help thread or a two-week silence becomes an ordinary event a journey can act on.

The journey

Working out who's gone quiet

Discord exposes presence but no last-seen timestamp, and presence is noisy — a member can show online for days without posting. The connector derives last_seen from every inbound event, so a journey computes real inactivity off the contact and emails only members who are linked, subscribed, and genuinely quiet.

reading the derived signal
// contacts.properties.discord.last_seen is a plain ISO string, derived
// first-party from observed events — never read back from Discord.
const meta = (contact.properties?.discord ?? {}) as { last_seen?: string };
const lastSeen = meta.last_seen ? new Date(meta.last_seen) : null;
const quietDays = lastSeen
  ? (Date.now() - lastSeen.getTime()) / 86_400_000
  : Infinity;

last_seen is a plain ISO string, derived first-party from observed events — never read back from Discord. A real message advances it; a background-tab presence dot does not.

src/journeys/re-engage-quiet-discord-members.ts
import { contacts } from "@hogsend/db";
import { days, defineJourney, getDb, hours, sendEmail } from "@hogsend/engine";
import { eq } from "drizzle-orm";
import { Events, Templates } from "./constants/index.js";

export const reEngageQuietDiscordMembers = defineJourney({
  meta: {
    id: "re-engage-quiet-discord-members",
    name: "Retention — re-engage quiet Discord members",
    enabled: true,
    // Re-evaluate on each presence ping; the entry guard rate-limits re-entry.
    trigger: { event: Events.DISCORD_PRESENCE_ACTIVE }, // "discord.presence_active"
    entryLimit: "once_per_period",
    entryPeriod: days(30),
    suppress: hours(12),
  },

  run: async (user, ctx) => {
    const db = getDb();
    const contact = await db.query.contacts.findFirst({
      where: eq(contacts.id, user.id),
    });

    // No linked email → nothing to send. last_seen is the DERIVED first-party
    // signal (max of observed Discord events), not Discord presence.
    if (!contact?.email) return;

    const meta = (contact.properties?.discord ?? {}) as {
      last_seen?: string;
      username?: string;
    };
    const lastSeen = meta.last_seen ? new Date(meta.last_seen) : null;
    if (!lastSeen) return;

    const quietDays = (Date.now() - lastSeen.getTime()) / 86_400_000;
    if (quietDays < 30) return; // still active enough — no win-back

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

    await sendEmail({
      to: contact.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.DISCORD_WINBACK, // "discord/winback"
      subject: `We've missed you in the server, ${meta.username ?? "friend"}`,
      journeyName: user.journeyName,
    });
  },
});

Three gates between trigger and send — a linked email, a last_seen older than 30 days, and a still-subscribed contact — so it never fires for the unlinked, the active, or the unsubscribed.

The run

The same model, executing

A member joins, their Discord identity becomes one PostHog person, activity keeps last_seen fresh, and a quiet stretch triggers the re-engagement.

src/journeys/discord-presence.ts
export const discordPresence = defineJourney({
meta: {
trigger: { event: Events.DISCORD_MEMBER_JOINED },
entryLimit: "once",
},
run: async (user, ctx) => {
// Their Discord identity becomes one PostHog person.
getPostHog()?.identify({
distinctId: user.id,
properties: { discord_id: user.discordId },
});
// Activity keeps last_active_at fresh on that same person.
const posted = await ctx.waitForEvent({
event: Events.DISCORD_MESSAGE_POSTED,
timeout: days(14),
});
// Gone quiet? Re-engage, and mark them dormant in PostHog.
if (posted.timedOut && !(await ctx.history.hasEvent({
userId: user.id,
event: Events.DISCORD_MESSAGE_POSTED,
within: days(14),
})).found) {
await sendEmail({ template: "discord/re-engage" });
getPostHog()?.capture({ event: "discord_dormant" });
}
},
});
The run
eventdiscord.member_joined · @newcomerenrolled
identify
PostHog
eventdiscord.message_posted · @newcomerenrolled
emit
PostHog
sleep
day 0 of 14
checkctx.history.hasEvent · discord.message_postedfound: false
joinedlinkedmessage_posted
send
We miss you in the community — here's what's newdeliveredopenedclicked
emit
PostHog
In production

Why it holds up

One contact, three identities

A member's anonymous web session, product account, and Discord handle stitch onto a single PostHog profile, so a community signal arrives with their product history attached.

last_seen

Discord has no last-seen field. The connector stamps contacts.properties.discord.last_seen from every inbound event, so the journey reads a real activity signal, not a presence flag.

Reading the thread

A journey is an async TypeScript function, so you can call an LLM inside run to classify a support thread — bug, feature request, or confusion — and branch on the answer.

Bounded re-entry

entryLimit: "once_per_period" with entryPeriod: days(30) caps re-entry to once a month, and ctx.guard.isSubscribed() gates every send, so a noisy trigger never becomes a noisy inbox.

A member runs the /link slash command once and confirms. From then on their Discord events and product events resolve to the same contact, stitched through PostHog identity. Until they link, a Discord-only member has no address to send to.

Start from the Discord journeys in the scaffold

The scaffold ships the welcome, the win-back, and the /link loop that puts a Discord handle on a contact. Edit them like any other journey.

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

terminal
pnpm dlx create-hogsend@latest my-app