workflow 5.0.0-beta.2 → 5.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/api-workflow.d.ts +1 -1
  2. package/dist/api-workflow.d.ts.map +1 -1
  3. package/dist/api-workflow.js +2 -2
  4. package/docs/cookbook/{common-patterns → advanced}/child-workflows.mdx +1 -1
  5. package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
  6. package/docs/cookbook/advanced/meta.json +2 -3
  7. package/docs/cookbook/advanced/publishing-libraries.mdx +83 -26
  8. package/docs/cookbook/advanced/serializable-steps.mdx +15 -3
  9. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
  10. package/docs/cookbook/agent-patterns/durable-agent.mdx +50 -91
  11. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +148 -171
  12. package/docs/cookbook/agent-patterns/meta.json +1 -7
  13. package/docs/cookbook/common-patterns/batching.mdx +44 -118
  14. package/docs/cookbook/common-patterns/meta.json +4 -4
  15. package/docs/cookbook/common-patterns/saga.mdx +126 -31
  16. package/docs/cookbook/common-patterns/scheduling.mdx +70 -194
  17. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
  18. package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
  19. package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
  20. package/docs/cookbook/index.mdx +13 -16
  21. package/docs/cookbook/integrations/ai-sdk.mdx +296 -140
  22. package/docs/cookbook/integrations/chat-sdk.mdx +251 -151
  23. package/docs/cookbook/integrations/sandbox.mdx +469 -81
  24. package/docs/cookbook/meta.json +1 -1
  25. package/docs/foundations/index.mdx +0 -3
  26. package/docs/foundations/meta.json +0 -1
  27. package/docs/foundations/serialization.mdx +1 -1
  28. package/docs/foundations/starting-workflows.mdx +1 -1
  29. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +60 -8
  30. package/docs/migration-guides/migrating-from-inngest.mdx +38 -6
  31. package/docs/migration-guides/migrating-from-temporal.mdx +38 -4
  32. package/docs/migration-guides/migrating-from-trigger-dev.mdx +52 -11
  33. package/package.json +11 -11
  34. package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
  35. package/docs/cookbook/advanced/durable-objects.mdx +0 -148
  36. package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
  37. package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
  38. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
  39. package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
  40. package/docs/cookbook/common-patterns/content-router.mdx +0 -207
  41. package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
  42. package/docs/foundations/common-patterns.mdx +0 -265
@@ -13,6 +13,12 @@ Use the saga pattern when a business transaction spans multiple services and you
13
13
  - Any sequence where partial completion leaves the system in an inconsistent state
14
14
  - Operations that need "all or nothing" semantics across external APIs
15
15
 
16
+ ## How it works
17
+
18
+ 1. Each forward step does work and registers a compensation function.
19
+ 2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency.
20
+ 3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help.
21
+
16
22
  ## Pattern
17
23
 
18
24
  Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order.
@@ -34,23 +40,21 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
34
40
  const compensations: Array<() => Promise<void>> = [];
35
41
 
36
42
  try {
37
- // Step 1: Reserve seats
38
43
  const reservationId = await reserveSeats(accountId, seats);
39
44
  compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]
40
45
 
41
- // Step 2: Capture payment
42
46
  const invoiceId = await captureInvoice(accountId, seats);
43
47
  compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]
44
48
 
45
- // Step 3: Provision access
46
49
  const entitlementId = await provisionSeats(accountId, seats);
47
50
  compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]
48
51
 
49
- // Step 4: Notify
52
+ // No compensation — notifications are fire-and-forget
50
53
  await sendConfirmation(accountId, invoiceId, entitlementId);
54
+
51
55
  return { status: "completed" };
52
56
  } catch (error) {
53
- // Unwind compensations in reverse order
57
+ // Unwind compensations in reverse (LIFO) order
54
58
  for (const compensate of compensations.reverse()) { // [!code highlight]
55
59
  await compensate(); // [!code highlight]
56
60
  }
@@ -62,11 +66,13 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
62
66
 
63
67
  ### Step functions
64
68
 
65
- Each step is a `"use step"` function with full Node.js access. Forward steps do the work; compensation steps undo it.
69
+ Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback.
66
70
 
67
71
  ```typescript
68
72
  import { FatalError } from "workflow";
69
73
 
74
+ // Forward steps
75
+
70
76
  async function reserveSeats(accountId: string, seats: number): Promise<string> {
71
77
  "use step";
72
78
  const res = await fetch(`https://api.example.com/seats/reserve`, {
@@ -78,15 +84,6 @@ async function reserveSeats(accountId: string, seats: number): Promise<string> {
78
84
  return reservationId;
79
85
  }
80
86
 
81
- async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
82
- "use step";
83
- // Compensations should be idempotent — safe to call twice
84
- await fetch(`https://api.example.com/seats/release`, {
85
- method: "POST",
86
- body: JSON.stringify({ accountId, reservationId }),
87
- });
88
- }
89
-
90
87
  async function captureInvoice(accountId: string, seats: number): Promise<string> {
91
88
  "use step";
92
89
  const res = await fetch(`https://api.example.com/invoices`, {
@@ -98,14 +95,6 @@ async function captureInvoice(accountId: string, seats: number): Promise<string>
98
95
  return invoiceId;
99
96
  }
100
97
 
101
- async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
102
- "use step";
103
- await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
104
- method: "POST",
105
- body: JSON.stringify({ accountId }),
106
- });
107
- }
108
-
109
98
  async function provisionSeats(accountId: string, seats: number): Promise<string> {
110
99
  "use step";
111
100
  const res = await fetch(`https://api.example.com/entitlements`, {
@@ -117,14 +106,6 @@ async function provisionSeats(accountId: string, seats: number): Promise<string>
117
106
  return entitlementId;
118
107
  }
119
108
 
120
- async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
121
- "use step";
122
- await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
123
- method: "DELETE",
124
- body: JSON.stringify({ accountId }),
125
- });
126
- }
127
-
128
109
  async function sendConfirmation(
129
110
  accountId: string,
130
111
  invoiceId: string,
@@ -136,17 +117,131 @@ async function sendConfirmation(
136
117
  body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
137
118
  });
138
119
  }
120
+
121
+ // Compensation steps — must be idempotent
122
+
123
+ async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
124
+ "use step";
125
+ await fetch(`https://api.example.com/seats/release`, {
126
+ method: "POST",
127
+ body: JSON.stringify({ accountId, reservationId }),
128
+ });
129
+ }
130
+
131
+ async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
132
+ "use step";
133
+ await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
134
+ method: "POST",
135
+ body: JSON.stringify({ accountId }),
136
+ });
137
+ }
138
+
139
+ async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
140
+ "use step";
141
+ await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
142
+ method: "DELETE",
143
+ body: JSON.stringify({ accountId }),
144
+ });
145
+ }
146
+ ```
147
+
148
+ ### Streaming step progress (optional)
149
+
150
+ Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time.
151
+
152
+ ```typescript
153
+ import { FatalError } from "workflow";
154
+ import { getWritable } from "workflow";
155
+
156
+ type SagaEvent =
157
+ | { type: "step_start"; step: string }
158
+ | { type: "step_done"; step: string; detail: string }
159
+ | { type: "step_failed"; step: string; error: string }
160
+ | { type: "compensating"; step: string }
161
+ | { type: "compensated"; step: string }
162
+ | { type: "result"; status: "completed" | "rolled_back" };
163
+
164
+ async function emit(event: SagaEvent) {
165
+ "use step";
166
+ const writer = getWritable<SagaEvent>().getWriter();
167
+ try {
168
+ await writer.write(event);
169
+ } finally {
170
+ writer.releaseLock();
171
+ }
172
+ }
173
+
174
+ declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
175
+ declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
176
+ declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
177
+ declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
178
+ declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
179
+ declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
180
+ declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup
181
+
182
+ export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
183
+ "use workflow";
184
+
185
+ const compensations: Array<{ name: string; execute: () => Promise<void> }> = [];
186
+
187
+ try {
188
+ await emit({ type: "step_start", step: "Reserve Seats" });
189
+ const reservationId = await reserveSeats(accountId, seats);
190
+ compensations.push({ name: "Release Seats", execute: () => releaseSeats(accountId, reservationId) });
191
+ await emit({ type: "step_done", step: "Reserve Seats", detail: reservationId });
192
+
193
+ await emit({ type: "step_start", step: "Capture Invoice" });
194
+ const invoiceId = await captureInvoice(accountId, seats);
195
+ compensations.push({ name: "Refund Invoice", execute: () => refundInvoice(accountId, invoiceId) });
196
+ await emit({ type: "step_done", step: "Capture Invoice", detail: invoiceId });
197
+
198
+ await emit({ type: "step_start", step: "Provision Seats" });
199
+ const entitlementId = await provisionSeats(accountId, seats);
200
+ compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) });
201
+ await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId });
202
+
203
+ // No compensation — notifications are fire-and-forget
204
+ await emit({ type: "step_start", step: "Send Confirmation" });
205
+ await sendConfirmation(accountId, invoiceId, entitlementId);
206
+ await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" });
207
+
208
+ await emit({ type: "result", status: "completed" });
209
+ return { status: "completed" };
210
+ } catch (error) {
211
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
212
+ await emit({ type: "step_failed", step: "failed", error: errorMessage });
213
+
214
+ // Unwind compensations in reverse (LIFO) order
215
+ for (const comp of compensations.reverse()) {
216
+ await emit({ type: "compensating", step: comp.name });
217
+ await comp.execute();
218
+ await emit({ type: "compensated", step: comp.name });
219
+ }
220
+
221
+ await emit({ type: "result", status: "rolled_back" });
222
+ return { status: "rolled_back" };
223
+ }
224
+ }
139
225
  ```
140
226
 
227
+ ## Adapting to your use case
228
+
229
+ - Replace the step functions with real API calls. Each `"use step"` function has full Node.js access.
230
+ - Add or remove steps as needed — the pattern scales to any number of steps.
231
+ - Make compensations idempotent — they may be retried if the workflow restarts mid-rollback.
232
+ - The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress.
233
+
141
234
  ## Tips
142
235
 
143
236
  - **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input).
144
237
  - **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again.
145
238
  - **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
146
239
  - **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
240
+ - **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation.
147
241
 
148
242
  ## Key APIs
149
243
 
150
244
  - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
151
245
  - [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
152
246
  - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
247
+ - [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates
@@ -2,248 +2,124 @@
2
2
  title: Sleep, Scheduling & Timed Workflows
3
3
  description: Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.
4
4
  type: guide
5
- summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events wake the workflow early.
5
+ summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events cancel the workflow early.
6
6
  ---
7
7
 
8
- Workflow's `sleep()` is durable -- it survives cold starts, restarts, and deployments. This makes it the foundation for scheduled actions, drip campaigns, reminders, and any pattern that needs to wait for real-world time to pass.
8
+ Workflow's `sleep()` is durable it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.
9
9
 
10
10
  ## When to use this
11
11
 
12
- - Sending emails on a schedule (drip campaigns, reminders, digests)
13
- - Waiting for a deadline before taking action
14
- - Any pattern where "do X, wait N hours, then do Y" needs to be reliable
12
+ - Sending emails on a schedule (drip campaigns, onboarding sequences, reminders)
13
+ - Waiting for a deadline but allowing early cancellation
14
+ - Any pattern where "do X, wait N hours, then do Y" needs to be both reliable and interruptible
15
15
 
16
- ## Pattern: Drip campaign
16
+ ## Drip campaign with cancellation
17
17
 
18
- Send emails at scheduled intervals using `sleep()` between steps. The workflow runs for days or weeks, sleeping between each email.
18
+ A drip campaign sends emails at intervals, sleeping between each. Each sleep races against a cancellation hook — if an external event fires the hook (e.g. user converts, unsubscribes), the campaign stops immediately.
19
19
 
20
20
  ```typescript
21
- import { sleep } from "workflow";
22
-
23
- export async function onboardingDrip(email: string) {
24
- "use workflow";
25
-
26
- await sendEmail(email, "welcome");
27
-
28
- await sleep("1d"); // [!code highlight]
29
- await sendEmail(email, "getting-started-tips");
30
-
31
- await sleep("2d"); // [!code highlight]
32
- await sendEmail(email, "feature-highlights");
21
+ import { defineHook, sleep } from "workflow";
33
22
 
34
- await sleep("4d"); // [!code highlight]
35
- await sendEmail(email, "follow-up");
36
-
37
- return { email, status: "completed", totalDays: 7 };
38
- }
23
+ // Hook that any API route can fire to cancel the drip
24
+ export const cancelDrip = defineHook<{ reason?: string }>(); // [!code highlight]
39
25
 
40
26
  async function sendEmail(email: string, template: string): Promise<void> {
41
27
  "use step";
42
28
  await fetch("https://api.sendgrid.com/v3/mail/send", {
43
29
  method: "POST",
44
30
  headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
45
- body: JSON.stringify({
46
- to: [{ email }],
47
- template_id: template,
48
- }),
31
+ body: JSON.stringify({ to: [{ email }], template_id: template }),
49
32
  });
50
33
  }
51
- ```
52
-
53
- ## Pattern: Interruptible reminder (sleep vs hook)
54
-
55
- Race a `sleep()` against a `defineHook` so external events can cancel, snooze, or send early:
56
-
57
- ```typescript
58
- import { defineHook, sleep } from "workflow";
59
-
60
- type ReminderAction =
61
- | { type: "cancel" }
62
- | { type: "send_now" }
63
- | { type: "snooze"; seconds: number };
64
-
65
- export const reminderActionHook = defineHook<ReminderAction>();
66
34
 
67
- export async function scheduleReminder(userId: string, delayMs: number) {
35
+ export async function emailSequence(email: string) {
68
36
  "use workflow";
69
37
 
70
- let sendAt = new Date(Date.now() + delayMs);
71
- const action = reminderActionHook.create({ token: `reminder:${userId}` });
72
-
73
- const outcome = await Promise.race([ // [!code highlight]
74
- sleep(sendAt).then(() => ({ kind: "time" as const })), // [!code highlight]
75
- action.then((payload) => ({ kind: "action" as const, payload })), // [!code highlight]
76
- ]);
77
-
78
- if (outcome.kind === "action") {
79
- if (outcome.payload.type === "cancel") {
80
- return { userId, status: "cancelled" };
81
- }
82
- if (outcome.payload.type === "snooze") {
83
- sendAt = new Date(Date.now() + outcome.payload.seconds * 1000);
84
- await sleep(sendAt);
85
- }
86
- // "send_now" falls through to send immediately
87
- }
38
+ await sendEmail(email, "welcome");
88
39
 
89
- await sendReminderEmail(userId);
90
- return { userId, status: "sent" };
91
- }
40
+ // Race durable sleep against the cancellation hook
41
+ const hook = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
42
+ const cancelled = await Promise.race([ // [!code highlight]
43
+ sleep("2d").then(() => false), // [!code highlight]
44
+ hook.then(() => true), // [!code highlight]
45
+ ]); // [!code highlight]
46
+ if (cancelled) return { status: "cancelled", email };
92
47
 
93
- async function sendReminderEmail(userId: string): Promise<void> {
94
- "use step";
95
- await fetch("https://api.example.com/reminders/send", {
96
- method: "POST",
97
- body: JSON.stringify({ userId }),
98
- });
99
- }
100
- ```
48
+ await sendEmail(email, "getting-started-tips");
101
49
 
102
- To wake the reminder early from an API route:
50
+ // Create a fresh hook for the next sleep window
51
+ const hook2 = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
52
+ const cancelled2 = await Promise.race([ // [!code highlight]
53
+ sleep("2d").then(() => false), // [!code highlight]
54
+ hook2.then(() => true), // [!code highlight]
55
+ ]); // [!code highlight]
56
+ if (cancelled2) return { status: "cancelled", email };
103
57
 
104
- ```typescript
105
- import { resumeHook } from "workflow/api";
58
+ await sendEmail(email, "feature-highlights");
106
59
 
107
- // POST /api/reminder/cancel
108
- export async function POST(request: Request) {
109
- const { userId } = await request.json();
110
- await resumeHook(`reminder:${userId}`, { type: "cancel" }); // [!code highlight]
111
- return Response.json({ ok: true });
60
+ return { status: "drip-complete", email };
112
61
  }
113
62
  ```
114
63
 
115
- ## Pattern: Timed collection window (digest)
64
+ ### Cancelling from an API route
116
65
 
117
- Open a collection window using `sleep()` and accumulate events from a hook until the window closes:
66
+ Any server-side code can fire the hook by calling `.resume()` with the same token:
118
67
 
119
68
  ```typescript
120
- import { sleep, defineHook } from "workflow";
121
-
122
- type EventPayload = { type: string; message: string };
123
-
124
- export const digestEvent = defineHook<EventPayload>();
125
-
126
- export async function collectAndSendDigest(
127
- digestId: string,
128
- userId: string,
129
- windowMs: number = 3_600_000
130
- ) {
131
- "use workflow";
69
+ import { cancelDrip } from "@/workflows/email-sequence";
132
70
 
133
- const hook = digestEvent.create({ token: `digest:${digestId}` });
134
- const windowClosed = sleep(`${windowMs}ms`).then(() => ({
135
- kind: "window_closed" as const,
136
- }));
137
- const events: EventPayload[] = [];
71
+ export async function POST(req: Request) {
72
+ const { email, reason } = await req.json();
138
73
 
139
- while (true) {
140
- const outcome = await Promise.race([ // [!code highlight]
141
- hook.then((payload) => ({ kind: "event" as const, payload })),
142
- windowClosed,
143
- ]);
144
-
145
- if (outcome.kind === "window_closed") break; // [!code highlight]
146
- events.push(outcome.payload);
147
- }
148
-
149
- if (events.length > 0) {
150
- await sendDigestEmail(userId, events);
74
+ if (!email) {
75
+ return Response.json({ error: "email is required" }, { status: 400 });
151
76
  }
152
77
 
153
- return { digestId, status: events.length > 0 ? "sent" : "empty", eventCount: events.length };
154
- }
155
-
156
- async function sendDigestEmail(userId: string, events: EventPayload[]): Promise<void> {
157
- "use step";
158
- await fetch("https://api.example.com/digest/send", {
159
- method: "POST",
160
- body: JSON.stringify({ userId, events }),
161
- });
162
- }
163
- ```
164
-
165
- ## Pattern: Timeout
166
-
167
- Add a timeout to any operation by racing it against `sleep()`:
168
-
169
- ```typescript
170
- import { sleep, FatalError } from "workflow";
171
-
172
- export async function processWithTimeout(jobId: string) {
173
- "use workflow";
174
-
175
- const result = await Promise.race([ // [!code highlight]
176
- processData(jobId),
177
- sleep("30s").then(() => "timeout" as const), // [!code highlight]
178
- ]);
179
-
180
- if (result === "timeout") {
181
- return { jobId, status: "timed_out" };
78
+ try {
79
+ await cancelDrip.resume(`cancel-drip:${email}`, { // [!code highlight]
80
+ reason: reason ?? "User completed action", // [!code highlight]
81
+ }); // [!code highlight]
82
+ } catch (error) {
83
+ const msg = error instanceof Error ? error.message.toLowerCase() : "";
84
+ if (msg.includes("not found") || msg.includes("expired")) {
85
+ return Response.json({
86
+ success: true,
87
+ email,
88
+ note: "No active drip found (already completed or cancelled)",
89
+ });
90
+ }
91
+ throw error;
182
92
  }
183
93
 
184
- return { jobId, status: "completed", result };
185
- }
186
-
187
- async function processData(jobId: string): Promise<string> {
188
- "use step";
189
- // Long-running computation
190
- return `result-for-${jobId}`;
94
+ return Response.json({ success: true, email });
191
95
  }
192
96
  ```
193
97
 
194
- ## Polling external services
195
-
196
- When you need to poll an external service until a job completes, define your own `sleep` as a step function and use it in a polling loop. Each iteration becomes a separate step in the event log, making the entire loop durable.
197
-
198
- ```typescript
199
- async function sleep(ms: number): Promise<void> {
200
- "use step";
201
- await new Promise(resolve => setTimeout(resolve, ms));
202
- }
203
-
204
- export async function waitForTranscription(jobId: string) {
205
- "use workflow";
98
+ ## How it works
206
99
 
207
- let status = "processing";
208
- let attempts = 0;
209
- const maxAttempts = 36; // ~3 minutes at 5s intervals
100
+ 1. **Durable sleep** — `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires.
101
+ 2. **Hook creation** — `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token.
102
+ 3. **Race** `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
103
+ 4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token.
210
104
 
211
- while (status === "processing" && attempts < maxAttempts) {
212
- await sleep(5000); // [!code highlight]
213
- attempts++;
214
- const result = await checkJobStatus(jobId); // [!code highlight]
215
- status = result.status;
216
- }
217
-
218
- if (status !== "completed") {
219
- return { jobId, status: "timed_out", attempts };
220
- }
221
-
222
- return { jobId, status: "completed", attempts };
223
- }
224
-
225
- async function checkJobStatus(jobId: string): Promise<{ status: string }> {
226
- "use step";
227
- const res = await fetch(`https://api.example.com/jobs/${jobId}`);
228
- return res.json();
229
- }
230
- ```
105
+ ## Adapting to your use case
231
106
 
232
- **When to use this vs `sleep()` from `workflow`:**
233
- - Use `sleep()` from `workflow` for fixed, known delays (drip campaigns, reminders, cooldowns).
234
- - Use a custom sleep-as-step for polling loops where you need to check a condition between sleeps. The custom step version also works in libraries that don't want to import from the `workflow` module directly.
107
+ - **Change durations** replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times.
108
+ - **Add more steps** the pattern scales to any number of email-then-sleep pairs.
109
+ - **Snooze instead of cancel** resolve the hook with a `snooze` payload and sleep again: `sleep(new Date(Date.now() + payload.snoozeMs))`.
110
+ - **Timeout any operation** — the same `Promise.race(sleep, work)` pattern works for adding deadlines to slow steps.
111
+ - **Real providers** — swap the `sendEmail` step body for Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access.
235
112
 
236
113
  ## Tips
237
114
 
238
115
  - **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time.
239
- - **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping -- no compute, no memory. It resumes precisely when the timer fires.
240
- - **Race `sleep` against `defineHook`** for interruptible waits. This is the standard pattern for reminders, approvals with deadlines, and timed collection windows.
241
- - **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use a standard `setTimeout` or return control to the workflow.
116
+ - **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping no compute, no memory.
117
+ - **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step.
242
118
 
243
119
  ## Key APIs
244
120
 
245
- - [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
246
- - [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
247
- - [`sleep()`](/docs/api-reference/workflow/sleep) -- durable wait (survives restarts, zero compute cost while sleeping)
248
- - [`defineHook`](/docs/api-reference/workflow/define-hook) -- creates a hook that external systems can trigger
249
- - [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) -- races sleep against hooks for interruptible waits
121
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps) marks the orchestrator function
122
+ - [`"use step"`](/docs/foundations/workflows-and-steps) marks functions that run with full Node.js access
123
+ - [`sleep()`](/docs/api-reference/workflow/sleep) durable wait (survives restarts, zero compute cost)
124
+ - [`defineHook()`](/docs/api-reference/workflow/define-hook) creates a typed hook that external systems can fire
125
+ - [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) races sleep against hooks for interruptible waits