workflow 5.0.0-beta.1 → 5.0.0-beta.2

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 (68) hide show
  1. package/README.md +4 -4
  2. package/dist/api-workflow.js +1 -1
  3. package/dist/api.js +1 -1
  4. package/dist/astro.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/internal/builtins.js +1 -1
  7. package/dist/internal/class-serialization.js +1 -1
  8. package/dist/internal/errors.js +1 -1
  9. package/dist/nest.js +1 -1
  10. package/dist/next.cjs +1 -1
  11. package/dist/nitro.js +1 -1
  12. package/dist/nuxt.js +1 -1
  13. package/dist/observability.js +1 -1
  14. package/dist/runtime.js +1 -1
  15. package/dist/stdlib.js +1 -1
  16. package/dist/sveltekit.js +1 -1
  17. package/dist/typescript-plugin.cjs +1 -1
  18. package/dist/vite.js +1 -1
  19. package/dist/workflow.js +1 -1
  20. package/docs/ai/resumable-streams.mdx +1 -1
  21. package/docs/api-reference/workflow/create-webhook.mdx +37 -18
  22. package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
  23. package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
  24. package/docs/api-reference/workflow-ai/index.mdx +0 -5
  25. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
  26. package/docs/cookbook/advanced/custom-serialization.mdx +168 -0
  27. package/docs/cookbook/advanced/durable-objects.mdx +148 -0
  28. package/docs/cookbook/advanced/isomorphic-packages.mdx +145 -0
  29. package/docs/cookbook/advanced/meta.json +10 -0
  30. package/docs/cookbook/advanced/publishing-libraries.mdx +279 -0
  31. package/docs/cookbook/advanced/serializable-steps.mdx +135 -0
  32. package/docs/cookbook/agent-patterns/durable-agent.mdx +191 -0
  33. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +278 -0
  34. package/docs/cookbook/agent-patterns/meta.json +10 -0
  35. package/docs/cookbook/agent-patterns/stop-workflow.mdx +216 -0
  36. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +255 -0
  37. package/docs/cookbook/agent-patterns/tool-streaming.mdx +181 -0
  38. package/docs/cookbook/common-patterns/batching.mdx +179 -0
  39. package/docs/cookbook/common-patterns/child-workflows.mdx +372 -0
  40. package/docs/cookbook/common-patterns/content-router.mdx +207 -0
  41. package/docs/cookbook/common-patterns/fan-out.mdx +208 -0
  42. package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
  43. package/docs/cookbook/common-patterns/meta.json +15 -0
  44. package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
  45. package/docs/cookbook/common-patterns/saga.mdx +152 -0
  46. package/docs/cookbook/common-patterns/scheduling.mdx +249 -0
  47. package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
  48. package/docs/cookbook/index.mdx +41 -0
  49. package/docs/cookbook/integrations/ai-sdk.mdx +204 -0
  50. package/docs/cookbook/integrations/chat-sdk.mdx +203 -0
  51. package/docs/cookbook/integrations/meta.json +4 -0
  52. package/docs/cookbook/integrations/sandbox.mdx +128 -0
  53. package/docs/cookbook/meta.json +5 -0
  54. package/docs/deploying/world/local-world.mdx +1 -1
  55. package/docs/deploying/world/postgres-world.mdx +1 -1
  56. package/docs/deploying/world/vercel-world.mdx +1 -1
  57. package/docs/errors/start-invalid-workflow-function.mdx +1 -1
  58. package/docs/getting-started/index.mdx +8 -1
  59. package/docs/getting-started/meta.json +2 -1
  60. package/docs/getting-started/python.mdx +165 -0
  61. package/docs/meta.json +1 -0
  62. package/docs/migration-guides/index.mdx +34 -0
  63. package/docs/migration-guides/meta.json +9 -0
  64. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +311 -0
  65. package/docs/migration-guides/migrating-from-inngest.mdx +282 -0
  66. package/docs/migration-guides/migrating-from-temporal.mdx +284 -0
  67. package/docs/migration-guides/migrating-from-trigger-dev.mdx +296 -0
  68. package/package.json +13 -13
@@ -0,0 +1,228 @@
1
+ ---
2
+ title: Rate Limiting & Retries
3
+ description: Handle 429 responses and transient failures with RetryableError and exponential backoff.
4
+ type: guide
5
+ summary: When an external API returns 429, throw RetryableError with the Retry-After value so the workflow runtime automatically reschedules the step after the specified delay.
6
+ ---
7
+
8
+ Use this pattern when calling external APIs that enforce rate limits. Instead of writing manual retry loops, throw `RetryableError` with a `retryAfter` value and let the workflow runtime handle rescheduling.
9
+
10
+ ## When to use this
11
+
12
+ - Calling APIs that return 429 (Too Many Requests) with `Retry-After` headers
13
+ - Any step that hits transient failures and needs backoff
14
+ - Syncing data with third-party services (Stripe, CRMs, scrapers)
15
+
16
+ ## Pattern: RetryableError with Retry-After
17
+
18
+ A step function calls an external API. On 429, it reads the `Retry-After` header and throws `RetryableError`. The runtime reschedules the step automatically.
19
+
20
+ ```typescript
21
+ import { RetryableError } from "workflow";
22
+
23
+ declare function fetchFromCrm(contactId: string): Promise<unknown>; // @setup
24
+ declare function upsertToWarehouse(contactId: string, contact: unknown): Promise<void>; // @setup
25
+
26
+ export async function syncContact(contactId: string) {
27
+ "use workflow";
28
+
29
+ const contact = await fetchFromCrm(contactId);
30
+ await upsertToWarehouse(contactId, contact);
31
+
32
+ return { contactId, status: "synced" };
33
+ }
34
+ ```
35
+
36
+ ### Step function with rate limit handling
37
+
38
+ ```typescript
39
+ import { RetryableError } from "workflow";
40
+
41
+ async function fetchFromCrm(contactId: string) {
42
+ "use step";
43
+
44
+ const res = await fetch(`https://crm.example.com/contacts/${contactId}`);
45
+
46
+ if (res.status === 429) { // [!code highlight]
47
+ const retryAfter = res.headers.get("Retry-After");
48
+ throw new RetryableError("Rate limited by CRM", { // [!code highlight]
49
+ retryAfter: retryAfter ? parseInt(retryAfter) * 1000 : "1m",
50
+ });
51
+ }
52
+
53
+ if (!res.ok) throw new Error(`CRM returned ${res.status}`);
54
+ return res.json();
55
+ }
56
+
57
+ async function upsertToWarehouse(contactId: string, contact: unknown) {
58
+ "use step";
59
+ await fetch(`https://warehouse.example.com/contacts/${contactId}`, {
60
+ method: "PUT",
61
+ body: JSON.stringify(contact),
62
+ });
63
+ }
64
+ ```
65
+
66
+ ## Pattern: Exponential backoff
67
+
68
+ Use `getStepMetadata()` to access the current attempt number and calculate increasing delays:
69
+
70
+ ```typescript
71
+ import { RetryableError, getStepMetadata } from "workflow";
72
+
73
+ async function callFlakeyApi(endpoint: string) {
74
+ "use step";
75
+
76
+ const { attempt } = getStepMetadata(); // [!code highlight]
77
+ const res = await fetch(endpoint);
78
+
79
+ if (res.status === 429 || res.status >= 500) {
80
+ throw new RetryableError(`Request failed (${res.status})`, { // [!code highlight]
81
+ retryAfter: (attempt ** 2) * 1000, // 1s, 4s, 9s... // [!code highlight]
82
+ });
83
+ }
84
+
85
+ return res.json();
86
+ }
87
+ ```
88
+
89
+ ## Pattern: Circuit breaker with sleep
90
+
91
+ When a dependency is completely down, stop hitting it for a cooldown period using `sleep()`, then probe with a single test request:
92
+
93
+ ```typescript
94
+ import { sleep } from "workflow";
95
+
96
+ export async function circuitBreaker(maxRequests: number = 10) {
97
+ "use workflow";
98
+
99
+ let state: "closed" | "open" | "half-open" = "closed";
100
+ let consecutiveFailures = 0;
101
+ const FAILURE_THRESHOLD = 3;
102
+
103
+ for (let i = 1; i <= maxRequests; i++) {
104
+ if (state === "open") {
105
+ await sleep("30s"); // Durable cooldown // [!code highlight]
106
+ state = "half-open";
107
+ }
108
+
109
+ const success = await callService(i);
110
+
111
+ if (success) {
112
+ consecutiveFailures = 0;
113
+ if (state === "half-open") state = "closed";
114
+ } else {
115
+ consecutiveFailures++;
116
+ if (consecutiveFailures >= FAILURE_THRESHOLD) {
117
+ state = "open";
118
+ consecutiveFailures = 0;
119
+ }
120
+ }
121
+ }
122
+
123
+ return { status: state === "closed" ? "recovered" : "failed" };
124
+ }
125
+
126
+ async function callService(requestNum: number): Promise<boolean> {
127
+ "use step";
128
+ try {
129
+ const res = await fetch("https://payment-gateway.example.com/charge");
130
+ return res.ok;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+ ```
136
+
137
+ ## Pattern: Custom max retries
138
+
139
+ Override the default retry count (3) for steps that need more or fewer attempts:
140
+
141
+ ```typescript
142
+ async function fetchWithRetries(url: string) {
143
+ "use step";
144
+ const res = await fetch(url);
145
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
146
+ return res.json();
147
+ }
148
+
149
+ // Allow up to 10 retry attempts
150
+ fetchWithRetries.maxRetries = 10; // [!code highlight]
151
+ ```
152
+
153
+ ## Application-level retry
154
+
155
+ Sometimes you need retry logic at the workflow level -- wrapping a step call with your own backoff instead of relying on the framework's built-in `RetryableError`. This is useful when you want full control over retry conditions, delays, and error filtering.
156
+
157
+ ```typescript
158
+ interface RetryOptions {
159
+ maxRetries?: number;
160
+ baseDelay?: number;
161
+ maxDelay?: number;
162
+ shouldRetry?: (error: Error, attempt: number) => boolean;
163
+ }
164
+
165
+ async function withRetry<T>(
166
+ fn: () => Promise<T>,
167
+ options: RetryOptions = {},
168
+ ): Promise<T> {
169
+ const { maxRetries = 3, baseDelay = 2000, maxDelay = 10000, shouldRetry } = options;
170
+ let lastError: Error | undefined;
171
+
172
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
173
+ try {
174
+ return await fn();
175
+ } catch (error) {
176
+ lastError = error instanceof Error ? error : new Error(String(error));
177
+ const isLastAttempt = attempt === maxRetries;
178
+ if (isLastAttempt || (shouldRetry && !shouldRetry(lastError, attempt + 1))) {
179
+ throw lastError;
180
+ }
181
+ // Exponential backoff with jitter
182
+ const delay = Math.min(baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5), maxDelay);
183
+ await new Promise(resolve => setTimeout(resolve, delay));
184
+ }
185
+ }
186
+
187
+ throw lastError;
188
+ }
189
+ ```
190
+
191
+ Use it in a workflow to wrap step calls:
192
+
193
+ ```typescript
194
+ declare function withRetry<T>(fn: () => Promise<T>, options?: { maxRetries?: number; shouldRetry?: (error: Error) => boolean }): Promise<T>; // @setup
195
+ declare function downloadFile(url: string): Promise<any>; // @setup
196
+
197
+ export async function downloadWithRetry(url: string) {
198
+ "use workflow";
199
+
200
+ const result = await withRetry(() => downloadFile(url), { // [!code highlight]
201
+ maxRetries: 5,
202
+ shouldRetry: (error) => error.message.includes("Timeout"),
203
+ });
204
+
205
+ return result;
206
+ }
207
+ ```
208
+
209
+ **When to use this vs `RetryableError`/`FatalError`:**
210
+ - **`RetryableError`** runs inside a step -- the framework reschedules the step after the delay. Use it for transient HTTP errors (429, 503) where the runtime should handle backoff.
211
+ - **Application-level retry** wraps the step call from the workflow. Use it when you need custom retry conditions, want to retry across different steps, or when you're building a library and prefer not to depend on workflow-specific error classes.
212
+
213
+ ## Tips
214
+
215
+ - **`RetryableError` is for transient failures.** Use it when the request might succeed on a later attempt (429, 503, network timeout).
216
+ - **`FatalError` is for permanent failures.** Use it when retrying won't help (404, 401, invalid input). This skips all remaining retries.
217
+ - **The `retryAfter` option accepts** a millisecond number, a duration string (`"1m"`, `"30s"`), or a `Date` object.
218
+ - **Steps retry up to 3 times by default.** Set `fn.maxRetries = N` to change this per step function.
219
+ - **Don't write manual sleep-retry loops.** The runtime handles scheduling natively with `RetryableError` -- it's more efficient and survives cold starts.
220
+
221
+ ## Key APIs
222
+
223
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
224
+ - [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
225
+ - [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
226
+ - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
227
+ - [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the current attempt number and step ID
228
+ - [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns
@@ -0,0 +1,152 @@
1
+ ---
2
+ title: Transactions & Rollbacks (Saga)
3
+ description: Coordinate multi-step transactions with automatic rollback when a step fails.
4
+ type: guide
5
+ summary: Run a sequence of steps where each registers a compensation. If any step throws a FatalError, compensations execute in reverse order to restore consistency.
6
+ ---
7
+
8
+ Use the saga pattern when a business transaction spans multiple services and you need automatic rollback if any step fails. Each forward step registers a compensation, and on failure the workflow unwinds them in reverse order.
9
+
10
+ ## When to use this
11
+
12
+ - Multi-service transactions (reserve inventory, charge payment, provision access)
13
+ - Any sequence where partial completion leaves the system in an inconsistent state
14
+ - Operations that need "all or nothing" semantics across external APIs
15
+
16
+ ## Pattern
17
+
18
+ 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.
19
+
20
+ ```typescript
21
+ import { FatalError } from "workflow";
22
+
23
+ declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
24
+ declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
25
+ declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
26
+ declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
27
+ declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
28
+ declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
29
+ declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup
30
+
31
+ export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
32
+ "use workflow";
33
+
34
+ const compensations: Array<() => Promise<void>> = [];
35
+
36
+ try {
37
+ // Step 1: Reserve seats
38
+ const reservationId = await reserveSeats(accountId, seats);
39
+ compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]
40
+
41
+ // Step 2: Capture payment
42
+ const invoiceId = await captureInvoice(accountId, seats);
43
+ compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]
44
+
45
+ // Step 3: Provision access
46
+ const entitlementId = await provisionSeats(accountId, seats);
47
+ compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]
48
+
49
+ // Step 4: Notify
50
+ await sendConfirmation(accountId, invoiceId, entitlementId);
51
+ return { status: "completed" };
52
+ } catch (error) {
53
+ // Unwind compensations in reverse order
54
+ for (const compensate of compensations.reverse()) { // [!code highlight]
55
+ await compensate(); // [!code highlight]
56
+ }
57
+
58
+ return { status: "rolled_back" };
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Step functions
64
+
65
+ Each step is a `"use step"` function with full Node.js access. Forward steps do the work; compensation steps undo it.
66
+
67
+ ```typescript
68
+ import { FatalError } from "workflow";
69
+
70
+ async function reserveSeats(accountId: string, seats: number): Promise<string> {
71
+ "use step";
72
+ const res = await fetch(`https://api.example.com/seats/reserve`, {
73
+ method: "POST",
74
+ body: JSON.stringify({ accountId, seats }),
75
+ });
76
+ if (!res.ok) throw new FatalError("Seat reservation failed"); // [!code highlight]
77
+ const { reservationId } = await res.json();
78
+ return reservationId;
79
+ }
80
+
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
+ async function captureInvoice(accountId: string, seats: number): Promise<string> {
91
+ "use step";
92
+ const res = await fetch(`https://api.example.com/invoices`, {
93
+ method: "POST",
94
+ body: JSON.stringify({ accountId, seats }),
95
+ });
96
+ if (!res.ok) throw new FatalError("Invoice capture failed"); // [!code highlight]
97
+ const { invoiceId } = await res.json();
98
+ return invoiceId;
99
+ }
100
+
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
+ async function provisionSeats(accountId: string, seats: number): Promise<string> {
110
+ "use step";
111
+ const res = await fetch(`https://api.example.com/entitlements`, {
112
+ method: "POST",
113
+ body: JSON.stringify({ accountId, seats }),
114
+ });
115
+ if (!res.ok) throw new FatalError("Provisioning failed"); // [!code highlight]
116
+ const { entitlementId } = await res.json();
117
+ return entitlementId;
118
+ }
119
+
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
+ async function sendConfirmation(
129
+ accountId: string,
130
+ invoiceId: string,
131
+ entitlementId: string
132
+ ): Promise<void> {
133
+ "use step";
134
+ await fetch(`https://api.example.com/notifications`, {
135
+ method: "POST",
136
+ body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
137
+ });
138
+ }
139
+ ```
140
+
141
+ ## Tips
142
+
143
+ - **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
+ - **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
+ - **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
146
+ - **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
147
+
148
+ ## Key APIs
149
+
150
+ - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
151
+ - [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
152
+ - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
@@ -0,0 +1,249 @@
1
+ ---
2
+ title: Sleep, Scheduling & Timed Workflows
3
+ description: Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.
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.
6
+ ---
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.
9
+
10
+ ## When to use this
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
15
+
16
+ ## Pattern: Drip campaign
17
+
18
+ Send emails at scheduled intervals using `sleep()` between steps. The workflow runs for days or weeks, sleeping between each email.
19
+
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");
33
+
34
+ await sleep("4d"); // [!code highlight]
35
+ await sendEmail(email, "follow-up");
36
+
37
+ return { email, status: "completed", totalDays: 7 };
38
+ }
39
+
40
+ async function sendEmail(email: string, template: string): Promise<void> {
41
+ "use step";
42
+ await fetch("https://api.sendgrid.com/v3/mail/send", {
43
+ method: "POST",
44
+ headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
45
+ body: JSON.stringify({
46
+ to: [{ email }],
47
+ template_id: template,
48
+ }),
49
+ });
50
+ }
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
+
67
+ export async function scheduleReminder(userId: string, delayMs: number) {
68
+ "use workflow";
69
+
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
+ }
88
+
89
+ await sendReminderEmail(userId);
90
+ return { userId, status: "sent" };
91
+ }
92
+
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
+ ```
101
+
102
+ To wake the reminder early from an API route:
103
+
104
+ ```typescript
105
+ import { resumeHook } from "workflow/api";
106
+
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 });
112
+ }
113
+ ```
114
+
115
+ ## Pattern: Timed collection window (digest)
116
+
117
+ Open a collection window using `sleep()` and accumulate events from a hook until the window closes:
118
+
119
+ ```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";
132
+
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[] = [];
138
+
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);
151
+ }
152
+
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" };
182
+ }
183
+
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}`;
191
+ }
192
+ ```
193
+
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";
206
+
207
+ let status = "processing";
208
+ let attempts = 0;
209
+ const maxAttempts = 36; // ~3 minutes at 5s intervals
210
+
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
+ ```
231
+
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.
235
+
236
+ ## Tips
237
+
238
+ - **`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.
242
+
243
+ ## Key APIs
244
+
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