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
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.
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.
// 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.
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 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.
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.
Questions, answered
Short answers here; the docs go deeper.
Go deeper
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
pnpm dlx create-hogsend@latest my-app