AI-personalised onboarding
generateObject drafts typed email slots from a user-context bundle (recent events + email engagement + PostHog person props); the template owns all markup. A day-3 nudge fires if the key feature stays un-activated — featureToActivate survives the durable sleep in the Hatchet task's local scope.
A new signup gets a welcome email drafted by an Anthropic model rather than a hand-written copy block. The model fills a Zod-validated struct — subject line, body paragraph, tips, and the feature it recommends activating first. The React Email template renders the struct; the model never sees or produces HTML.
| Boundary | Owner |
|---|---|
| Email subject + body + tips | generateObject → OnboardingPlan (Zod-validated) |
| Template markup + layout + tracking | React Email component (code-owned) |
| Send pipeline (preferences, tracking) | sendEmail |
| Day-3 activation nudge | ctx.sleep(days(3)) → ctx.history.hasEvent |
What ships in the scaffold
Every app scaffolded with create-hogsend includes this journey out of the box:
src/agents/onboarding-concierge.ts— the agent function (draftOnboardingPlan)src/lib/user-context.ts— the context bundle helper (getUserContext)src/journeys/ai-onboarding.ts— the journey wiringsrc/emails/onboarding-personalized.tsx+src/emails/onboarding-nudge.tsx— the templates
Set ANTHROPIC_API_KEY in .env and fire a user.created event to see it end to end.
The context bundle
The agent needs to know who it's talking to. getUserContext pulls from three sources in parallel:
// src/lib/user-context.ts
const [events, openedResult, clickedResult, posthogProps] = await Promise.all([
ctx.history.events({ userId: user.id, limit: 50 }), // newest-first from user_events
ctx.history.hasEvent({ userId: user.id, event: "email.opened" }),
ctx.history.hasEvent({ userId: user.id, event: "email.link_clicked" }),
getPostHog()?.getPersonProperties(user.id) ?? Promise.resolve(undefined),
]);ctx.history.events() reads the engine's own user_events table — no PostHog personal API key required. PostHog person props are additive: present when POSTHOG_PERSONAL_API_KEY is configured, omitted otherwise.
The agent
// src/agents/onboarding-concierge.ts
export const OnboardingPlan = z.object({
subject: z.string().describe("Concise, personalised subject (max 60 chars)."),
body: z.string().describe("Friendly 2–3 sentence opening paragraph."),
tips: z.array(z.string()).min(1).max(3).describe("1–3 actionable tips."),
featureToActivate: z.string().describe("The single most important feature to try first."),
});
export async function draftOnboardingPlan(context: UserContext): Promise<OnboardingPlanType> {
const { object } = await generateObject({
model: createAnthropic()("claude-haiku-4-5"),
schema: OnboardingPlan,
prompt: `…${JSON.stringify(context)}…`,
});
return object;
}generateObject validates the completion against OnboardingPlan before returning. If the model produces something that doesn't satisfy the schema, the call throws and the Hatchet task fails — Hatchet retries the whole function (context query + model call + send), which is safe because nothing has been sent yet.
The journey
// src/journeys/ai-onboarding.ts
export const aiOnboarding = defineJourney({
meta: {
id: "ai-onboarding",
name: "AI Onboarding — Personalised Welcome",
enabled: true,
trigger: { event: Events.USER_CREATED },
entryLimit: "once",
suppress: hours(12),
exitOn: [{ event: Events.USER_DELETED }],
},
run: async (user, ctx) => {
const context = await getUserContext(ctx, user);
const plan = await draftOnboardingPlan(context);
// Welcome email — immediately.
await sendEmail({
to: user.email, userId: user.id, journeyStateId: user.stateId,
template: Templates.ONBOARDING_PERSONALIZED,
subject: plan.subject,
props: {
name: user.email.split("@")[0] ?? "there",
body: plan.body, tips: plan.tips,
},
journeyName: user.journeyName,
});
// Wait three days — durable Hatchet sleep, survives a worker restart.
await ctx.sleep({ duration: days(3), label: "post-welcome-window" });
// plan.featureToActivate is still in scope — it lives in the Hatchet task's
// local state, not in a DB column. No extra write needed.
const { found: hasActivated } = await ctx.history.hasEvent({
userId: user.id, event: Events.FEATURE_ACTIVATED,
});
if (!hasActivated && await ctx.guard.isSubscribed()) {
await sendEmail({
to: user.email, userId: user.id, journeyStateId: user.stateId,
template: Templates.ONBOARDING_NUDGE,
subject: `Still haven't tried ${plan.featureToActivate}?`,
props: {
name: user.email.split("@")[0] ?? "there",
featureName: plan.featureToActivate,
},
journeyName: user.journeyName,
});
}
},
});Constants and registration
// src/journeys/constants/index.ts (additions)
export const Events = {
// …existing…
FEATURE_ACTIVATED: "feature.activated",
} as const;
export const Templates = {
// …existing…
ONBOARDING_PERSONALIZED: "onboarding/personalized",
ONBOARDING_NUDGE: "onboarding/nudge",
} as const;// src/journeys/index.ts
import { aiOnboarding } from "./ai-onboarding.js";
export const journeys: DefinedJourney[] = [aiOnboarding, /* …others… */];Test it locally
# 1. Start the worker (keeps journeys alive)
pnpm worker:dev
# 2. Fire a signup event via the data API
curl -X POST http://localhost:3002/v1/ingest \
-H "Authorization: Bearer $HOGSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"user.created","userId":"u_demo","email":"ada@example.com"}'
# 3. Watch the Hatchet dashboard (localhost:8888) — the ai-onboarding task
# appears and completes. The personalised email is sent via Resend.
# 4. Simulate no activation — after 3 days the nudge fires automatically.
# To test immediately, trigger a dormancy-check event or adjust the sleep
# duration to minutes(1) in dev.- The welcome plan is the nudge's input.
plan.featureToActivatepersists in the durable task's local scope across the sleep. You don't need a DB column or a separate lookup — Hatchet's durable execution keeps the in-memory value alive. - Emission timing matters. Emit
feature.activatedfrom your app code (e.g. the moment a user completes a key action). The nudge branch readsctx.history.hasEventat the 3-day mark, so any emission before that suppresses the nudge. entryLimit: "once"is a hard gate. A seconduser.createdfor the same userId is skipped — there's no risk of double-welcoming a user who re-triggers the event.- Model cost.
claude-haiku-4-5handles a short prompt + small schema at very low latency and cost. Swap the model string inonboarding-concierge.tsif you want a more capable model for this step.
Related: AI next-best action shows how to use generateText({ tools }) for a model that needs to pull history mid-reasoning; Vercel AI SDK integration covers all three tiers; Welcome series is the non-AI version of the same pattern.
Agent feedback loop
Confirmed semantic answers fan out to your agent through a filtered, signed webhook endpoint; the agent's verdict returns as a plain event via hs.events.send; the journey is parked on ctx.waitForEvent.
AI next-best action
generateText with tools lets the model pull user history mid-reasoning and commit a typed decision — including "suppress". The journey acts on the decision without knowing which model steps fired; a suppress decision exits with no send.