Skip to content

Commit fec09b2

Browse files
authored
docs(cookbooks): add welcome email cookbook (#3757)
* feat(examples): add welcome email durable workflow * docs(cookbooks): add welcome email cookbook * docs(cookbooks): add welcome email to cookbook index * test(examples): add welcome email trigger and e2e tests * fix(examples): handle early onboarding events in welcome email * docs(cookbooks): revise welcome email onboarding cookbook Update print/log statements in the examples so they tell a story aligned with the cookbook article. Revise the cookbook prose to clarify onboarding, scoped events, lookback behavior, and follow-up handling. * docs(cookbooks): Explained durable timeout, added missing language tab Address review feedback by explaining that the timeout is durable and adding a TypeScript tab for the worker registration section.
1 parent 25647a9 commit fec09b2

17 files changed

Lines changed: 690 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# > Trigger the workflow
2+
from examples.welcome_email.worker import (
3+
ONBOARDING_EVENT_KEY,
4+
SignupInput,
5+
hatchet,
6+
welcome_email,
7+
)
8+
9+
signup = SignupInput(
10+
email="alice@example.com",
11+
user_id="user-123",
12+
)
13+
14+
# Start the welcome-email workflow
15+
ref = welcome_email.run(signup, wait_for_result=False)
16+
print(f"Started workflow run: {ref.workflow_run_id}")
17+
18+
# Push onboarding-completed event (scoped to this user)
19+
print("Pushing onboarding-completed event...")
20+
hatchet.event.push(
21+
ONBOARDING_EVENT_KEY,
22+
{"status": "done"},
23+
scope=signup.user_id,
24+
)
25+
26+
# Wait for the workflow to complete
27+
result = ref.result()
28+
print(f"Workflow completed: {result}")
29+
30+
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from datetime import timedelta
2+
3+
from pydantic import BaseModel
4+
5+
from hatchet_sdk import (
6+
DurableContext,
7+
Hatchet,
8+
SleepCondition,
9+
UserEventCondition,
10+
or_,
11+
)
12+
13+
hatchet = Hatchet()
14+
15+
ONBOARDING_EVENT_KEY = "user:onboarding-completed"
16+
TIMEOUT_SECONDS = 5
17+
LOOKBACK_MINUTES = 5
18+
19+
20+
# > Models
21+
class SignupInput(BaseModel):
22+
email: str
23+
user_id: str
24+
25+
26+
class WelcomeEmailResult(BaseModel):
27+
user_id: str
28+
welcome_sent: bool
29+
follow_up_sent: bool
30+
31+
32+
33+
34+
# > Welcome email task
35+
@hatchet.durable_task(
36+
name="welcome-email",
37+
on_events=["user:signup"],
38+
input_validator=SignupInput,
39+
execution_timeout=timedelta(minutes=5),
40+
)
41+
async def welcome_email(input: SignupInput, ctx: DurableContext) -> WelcomeEmailResult:
42+
# Step 1: Send the welcome email
43+
print(f"Sending welcome email to {input.email}: finish your first onboarding step")
44+
45+
# Step 2: Wait for the user to complete onboarding, or time out
46+
# (use a longer duration for a more realistic workflow)
47+
now = await ctx.aio_now()
48+
consider_events_since = now - timedelta(minutes=LOOKBACK_MINUTES)
49+
50+
wait_result = await ctx.aio_wait_for(
51+
"onboarding-or-timeout",
52+
or_(
53+
SleepCondition(timedelta(seconds=TIMEOUT_SECONDS)),
54+
# Scope the event condition to this user so that another user's
55+
# onboarding-completed event does not resolve this wait.
56+
UserEventCondition(
57+
event_key=ONBOARDING_EVENT_KEY,
58+
scope=input.user_id,
59+
consider_events_since=consider_events_since,
60+
),
61+
),
62+
)
63+
64+
# The or-group result is {"CREATE": {"<condition_key>": ...}}.
65+
# Check whether the onboarding event was the one that resolved.
66+
resolved_key = list(wait_result["CREATE"].keys())[0]
67+
onboarding_completed = resolved_key == ONBOARDING_EVENT_KEY
68+
69+
if onboarding_completed:
70+
# Step 3a: User completed onboarding -> skip follow-up
71+
print(f"User {input.user_id} completed onboarding, skipping follow-up")
72+
return WelcomeEmailResult(
73+
user_id=input.user_id,
74+
welcome_sent=True,
75+
follow_up_sent=False,
76+
)
77+
78+
# Step 3b: Timeout -> send follow-up email
79+
print(f"Sending follow-up email to {input.email}: need help finishing onboarding?")
80+
return WelcomeEmailResult(
81+
user_id=input.user_id,
82+
welcome_sent=True,
83+
follow_up_sent=True,
84+
)
85+
86+
87+
88+
89+
# > Worker registration
90+
def main() -> None:
91+
worker = hatchet.worker("welcome-email-worker", workflows=[welcome_email])
92+
worker.start()
93+
94+
95+
if __name__ == "__main__":
96+
main()
97+
98+

examples/python/worker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
webhook_with_static_payload,
8989
)
9090
from examples.webhooks.worker import webhook
91+
from examples.welcome_email.worker import welcome_email
9192
from examples.opentelemetry_instrumentation.worker import (
9293
otel_workflow,
9394
otel_simple_task,
@@ -181,6 +182,7 @@ def main() -> None:
181182
triage_ticket,
182183
generate_reply,
183184
escalate_ticket,
185+
welcome_email,
184186
],
185187
lifespan=lifespan,
186188
)

examples/typescript/e2e-worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
generateReply,
6565
escalateTicket,
6666
} from './support_agent/workflow';
67+
import { welcomeEmail } from './welcome_email/workflow';
6768

6869
const workflows = [
6970
bulkChild,
@@ -123,6 +124,7 @@ const workflows = [
123124
triageTicket,
124125
generateReply,
125126
escalateTicket,
127+
welcomeEmail,
126128
];
127129

128130
async function main() {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// > Trigger the workflow
2+
import { hatchet } from '../hatchet-client';
3+
import { welcomeEmail, ONBOARDING_EVENT_KEY, SignupInput } from './workflow';
4+
5+
async function main() {
6+
const input: SignupInput = {
7+
email: 'alice@example.com',
8+
user_id: 'user-123',
9+
};
10+
11+
// Start the welcome-email workflow
12+
const ref = await welcomeEmail.runNoWait(input);
13+
const runId = await ref.getWorkflowRunId();
14+
console.log(`Started workflow run: ${runId}`);
15+
16+
// Push onboarding-completed event (scoped to this user)
17+
console.log('Pushing onboarding-completed event...');
18+
await hatchet.events.push(ONBOARDING_EVENT_KEY, { status: 'done' }, { scope: input.user_id });
19+
20+
// Wait for the workflow to complete
21+
const result = await ref.output;
22+
console.log('Workflow completed:', result);
23+
}
24+
25+
if (require.main === module) {
26+
main()
27+
.catch(console.error)
28+
.finally(() => {
29+
process.exit(0);
30+
});
31+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Or } from '@hatchet-dev/typescript-sdk/v1/conditions';
2+
import { durationToMs } from '@hatchet-dev/typescript-sdk/v1/client/duration';
3+
import { hatchet } from '../hatchet-client';
4+
5+
export const ONBOARDING_EVENT_KEY = 'user:onboarding-completed';
6+
const TIMEOUT_SECONDS = 5;
7+
const LOOKBACK_WINDOW = '5m' as const;
8+
9+
// > Models
10+
export type SignupInput = {
11+
email: string;
12+
user_id: string;
13+
};
14+
15+
export type WelcomeEmailResult = {
16+
userId: string;
17+
welcomeSent: boolean;
18+
followUpSent: boolean;
19+
};
20+
21+
// > Welcome email task
22+
export const welcomeEmail = hatchet.durableTask<SignupInput, WelcomeEmailResult>({
23+
name: 'welcome-email',
24+
onEvents: ['user:signup'],
25+
executionTimeout: '5m',
26+
fn: async (input, ctx) => {
27+
// Step 1: Send the welcome email
28+
console.log(`Sending welcome email to ${input.email}: finish your first onboarding step`);
29+
30+
// Step 2: Wait for the user to complete onboarding, or time out
31+
// (use a longer duration for a more realistic workflow)
32+
const now = await ctx.now();
33+
const considerEventsSince = new Date(
34+
now.getTime() - durationToMs(LOOKBACK_WINDOW)
35+
).toISOString();
36+
37+
const waitResult = await ctx.waitFor(
38+
Or(
39+
{ sleepFor: `${TIMEOUT_SECONDS}s` },
40+
// Scope the event condition to this user so that another user's
41+
// onboarding-completed event does not resolve this wait.
42+
{ eventKey: ONBOARDING_EVENT_KEY, scope: input.user_id, considerEventsSince }
43+
)
44+
);
45+
46+
// The or-group result is { CREATE: { <condition_key>: ... } }.
47+
// Check whether the onboarding event was the one that resolved.
48+
const create = (waitResult as Record<string, Record<string, unknown>>)['CREATE'] ?? waitResult;
49+
const resolvedKey = Object.keys(create as Record<string, unknown>)[0] ?? '';
50+
const onboardingCompleted = resolvedKey === ONBOARDING_EVENT_KEY;
51+
52+
if (onboardingCompleted) {
53+
// Step 3a: User completed onboarding -> skip follow-up
54+
console.log(`User ${input.user_id} completed onboarding, skipping follow-up`);
55+
return { userId: input.user_id, welcomeSent: true, followUpSent: false };
56+
}
57+
58+
// Step 3b: Timeout -> send follow-up email
59+
console.log(`Sending follow-up email to ${input.email}: need help finishing onboarding?`);
60+
return { userId: input.user_id, welcomeSent: true, followUpSent: true };
61+
},
62+
});

frontend/docs/pages/cookbooks/_meta.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export default {
1212
type: "separator",
1313
},
1414
"workflow-support-agent": "Support Agent",
15+
"welcome-email": "Welcome Email",
1516
};

frontend/docs/pages/cookbooks/index.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,8 @@ Build practical end-to-end workflows with Hatchet’s durable execution, event h
3333
Build a durable support workflow that triages tickets, waits for customer
3434
replies, and escalates on timeout.
3535
</Cards.Card>
36+
<Cards.Card title="Welcome Email" href="/cookbooks/welcome-email">
37+
Send a welcome email after signup, wait for onboarding completion, and send
38+
a follow-up only if the user does not finish in time.
39+
</Cards.Card>
3640
</Cards>

0 commit comments

Comments
 (0)