1---
2title: Add a Workflow
3description: Orchestrate durable, multi-step work with Cloudflare Workflows — automatic retries, replayable steps, and at-least-once delivery.
4sidebar:
5 order: 10
6---
7
8This guide builds a Workflow end to end: a durable multi-step job
9that writes to KV and broadcasts each task's progress back to a chat
10`Room` Durable Object. For the concept, start at
11[Workflows](/cloudflare/compute/workflows).
12
13## How a Workflow looks
14
15The two-phase shape — a constructor `Effect.gen` that returns an
16`Effect.fn` body — plus `task`, `sleep`, and the replay caution are
17covered on [Workflows](/cloudflare/compute/workflows).
18
19## Create the workflow file
20
21Create `src/NotifyWorkflow.ts` with a typed workflow body. The
22`Effect.fn` takes the `input` payload directly, so `notifier.create({
23params: { roomId, message } })` is type-checked end to end — and the
24workflow's return value flows through to `instance.status().output`:
25
26```typescript
27// src/NotifyWorkflow.ts
28import * as Cloudflare from "alchemy/Cloudflare";
29import * as Effect from "effect/Effect";
30import Room from "./room.ts";
31
32export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
33 "Notifier",
34 Effect.gen(function* () {
35 const rooms = yield* Room;
36
37 return Effect.fn(function* (input: { roomId: string; message: string }) {
38 const { roomId, message } = input;
39 return { roomId, message };
40 });
41 }),
42) {}
43```
44
45The constructor resolves shared dependencies — here, the `Room` DO
46namespace from the previous tutorial so we can broadcast back to
47it. The inner `Effect.fn` is the workflow body that the Cloudflare
48runtime executes task by task.
49
50## Declare a KV namespace
51
52Tasks need somewhere durable to read and write. Give the workflow
53a KV namespace by declaring it in its own file:
54
55```typescript
56// src/KV.ts
57import * as Cloudflare from "alchemy/Cloudflare";
58
59export const KV = Cloudflare.KV.Namespace("KV");
60```
61
62`Cloudflare.KV.Namespace("KV")` is just a description — Alchemy
63provisions the real namespace on the next deploy as soon as
64something binds to it.
65
66## Bind KV to the workflow
67
68`Cloudflare.KV.ReadWriteNamespace(KV)` belongs in the workflow's outer
69Construction phase. It registers the binding on the workflow's worker and
70returns a typed Effect-native client whose methods (`get`, `put`,
71`list`, `delete`) are Effects you can `yield*` directly:
72
73```diff lang="typescript"
74// src/NotifyWorkflow.ts
75import * as Cloudflare from "alchemy/Cloudflare";
76import * as Effect from "effect/Effect";
77+import { KV } from "./KV.ts";
78import Room from "./room.ts";
79
80export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
81 "Notifier",
82 Effect.gen(function* () {
83 const rooms = yield* Room;
84+ const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
85
86 return Effect.fn(function* (input: { roomId: string; message: string }) {
87 const { roomId, message } = input;
88 return { roomId, message };
89 });
90 }),
91) {}
92```
93
94Yielding the binding in the constructor is a one-time setup — the
95inner workflow body closes over `kv` and uses it on every run.
96
97## Add a task
98
99`Cloudflare.Workflows.task("name", effect)` runs the inner Effect inside a
100Cloudflare workflow step — the result is checkpointed so a crash +
101replay returns the persisted value instead of re-running the side
102effect. Wrap **anything** that touches the outside world — HTTP
103calls, binding I/O, file writes — in a task:
104
105```diff lang="typescript"
106// src/NotifyWorkflow.ts
107import * as Cloudflare from "alchemy/Cloudflare";
108import * as Effect from "effect/Effect";
109import { KV } from "./KV.ts";
110import Room from "./room.ts";
111
112export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
113 "Notifier",
114 Effect.gen(function* () {
115 const rooms = yield* Room;
116 const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
117
118 return Effect.fn(function* (input: { roomId: string; message: string }) {
119 const { roomId, message } = input;
120
121+ const stored = yield* Cloudflare.Workflows.task(
122+ "kv-roundtrip",
123+ Effect.gen(function* () {
124+ const key = `workflow:${roomId}`;
125+ yield* kv.put(key, message);
126+ return yield* kv.get(key);
127+ }).pipe(Effect.orDie),
128+ );
129
130- return { roomId, message };
131+ return { roomId, message: stored };
132 });
133 }),
134) {}
135```
136
137`task` automatically threads the binding's service requirement
138through, so `kv.put` / `kv.get` work directly inside the inner
139Effect with no extra plumbing.
140
141:::caution[Always wrap I/O in `task`]
142The body Effect can re-execute many times during a replay. Anything
143you do outside a `task` (like a raw `kv.put`) runs on every replay.
144Wrapping the I/O in `task` makes the result persisted and the side
145effect run exactly once.
146:::
147
148## Broadcast to the chat room
149
150Add another task that fans the stored value out to the matching
151`Room` instance:
152
153```diff lang="typescript"
154// src/NotifyWorkflow.ts
155import * as Cloudflare from "alchemy/Cloudflare";
156import * as Effect from "effect/Effect";
157import { KV } from "./KV.ts";
158import Room from "./room.ts";
159
160export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
161 "Notifier",
162 Effect.gen(function* () {
163 const rooms = yield* Room;
164 const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
165
166 return Effect.fn(function* (input: { roomId: string; message: string }) {
167 const { roomId, message } = input;
168
169 const stored = yield* Cloudflare.Workflows.task(
170 "kv-roundtrip",
171 Effect.gen(function* () {
172 const key = `workflow:${roomId}`;
173 yield* kv.put(key, message);
174 return yield* kv.get(key);
175 }).pipe(Effect.orDie),
176 );
177
178+ const room = rooms.getByName(roomId);
179+ yield* Cloudflare.Workflows.task(
180+ "broadcast",
181+ room.broadcast(`[workflow] ${stored}`),
182+ );
183
184 return { roomId, message: stored };
185 });
186 }),
187) {}
188```
189
190Calling the DO's `broadcast` RPC method from inside a `task` makes
191the message-send durable too — replays don't double-broadcast.
192
193## Sleep between tasks
194
195`Cloudflare.Workflows.sleep("name", "2 seconds")` parks the workflow without
196billing for compute, then resumes at the requested time. Names are
197required because Cloudflare uses them as replay keys:
198
199```diff lang="typescript"
200// src/NotifyWorkflow.ts
201import * as Cloudflare from "alchemy/Cloudflare";
202import * as Effect from "effect/Effect";
203import { KV } from "./KV.ts";
204import Room from "./room.ts";
205
206export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
207 "Notifier",
208 Effect.gen(function* () {
209 const rooms = yield* Room;
210 const kv = yield* Cloudflare.KV.ReadWriteNamespace(KV);
211
212 return Effect.fn(function* (input: { roomId: string; message: string }) {
213 const { roomId, message } = input;
214
215 const stored = yield* Cloudflare.Workflows.task(
216 "kv-roundtrip",
217 Effect.gen(function* () {
218 const key = `workflow:${roomId}`;
219 yield* kv.put(key, message);
220 return yield* kv.get(key);
221 }).pipe(Effect.orDie),
222 );
223
224 const room = rooms.getByName(roomId);
225 yield* Cloudflare.Workflows.task(
226 "broadcast",
227 room.broadcast(`[workflow] ${stored}`),
228 );
229
230+ yield* Cloudflare.Workflows.sleep("cooldown", "2 seconds");
231+
232+ yield* Cloudflare.Workflows.task(
233+ "finalize",
234+ room.broadcast(`[workflow] complete for ${roomId}`),
235+ );
236
237 return { roomId, message: stored };
238 });
239 }),
240) {}
241```
242
243After the cool-down the workflow broadcasts a "complete" message
244and finishes. The whole sequence — KV roundtrip → broadcast →
245sleep → broadcast → return — is durable end to end.
246
247## Bind a secret
248
249Most real workflows need credentials — an upstream API key, a
250signing token, etc. `Config.Redacted` registers a `secret_text`
251binding on the workflow at plantime and hands back a
252`Redacted<string>` for use inside steps:
253
254```diff lang="typescript"
255 import * as Cloudflare from "alchemy/Cloudflare";
256+import * as Config from "effect/Config";
257 import * as Effect from "effect/Effect";
258 import Room from "./room.ts";
259
260 export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
261 "Notifier",
262 Effect.gen(function* () {
263 const rooms = yield* Room;
264+ const apiKey = yield* Config.Redacted("API_KEY");
265```
266
267`Config.Redacted("API_KEY")` reads `API_KEY` from the active
268`Config` provider (env vars, `.env`, …) at plantime and binds it
269into the workflow as `secret_text`. Set it in your local `.env`:
270
271```sh
272# .env
273API_KEY=sk-test-123
274```
275
276See [Concepts › Secrets](/environments/secrets) for the full set of
277input shapes (literals, `Effect`, `Config`, …).
278
279## Use the secret in a task
280
281The `Redacted<string>` resolved in the constructor is captured by the Runtime
282body. Unwrap with `Redacted.value` only at the call
283site that needs it (here, an `Authorization` header):
284
285```diff lang="typescript"
286+import * as Redacted from "effect/Redacted";
287+import * as HttpClient from "effect/http/HttpClient";
288+import * as HttpClientRequest from "effect/http/HttpClientRequest";
289
290 export default class NotifyWorkflow extends Cloudflare.Workflow<NotifyWorkflow>()(
291 "Notifier",
292 Effect.gen(function* () {
293 const rooms = yield* Room;
294 const apiKey = yield* Config.Redacted("API_KEY");
295
296 return Effect.gen(function* () {
297 const env = yield* Cloudflare.Workers.WorkerEnvironment;
298 const event = yield* Cloudflare.Workflows.WorkflowEvent;
299 const { roomId, message } = event.payload as {
300 roomId: string;
301 message: string;
302 };
303
304 const stored = yield* Cloudflare.Workflows.task("kv-roundtrip", /* ... */);
305
306 const room = rooms.getByName(roomId);
307 yield* Cloudflare.Workflows.task(
308 "broadcast",
309 room.broadcast(`[workflow] ${stored}`),
310 );
311
312+ yield* Cloudflare.Workflows.task(
313+ "fetch-with-auth",
314+ HttpClientRequest.get("https://example.com/data").pipe(
315+ HttpClientRequest.bearerToken(Redacted.value(apiKey)),
316+ HttpClient.execute,
317+ Effect.flatMap((r) => r.text),
318+ ),
319+ );
320
321 return { roomId, message: stored };
322 });
323 }),
324 ) {}
325```
326
327`HttpClient.execute` runs inside
328`Cloudflare.Workflows.task`, so the response is checkpointed and the
329request only fires once across replays. `Redacted.value` is
330called at the single header site that needs the cleartext;
331elsewhere the value stays redacted.
332
333## Trigger from the Worker
334
335A Workflow becomes a typed handle when you `yield*` it in the
336Worker's Construction phase. Use `create()` to start an instance and
337`get(id).status()` to poll it:
338
339```diff lang="typescript"
340// src/worker.ts
341import * as Cloudflare from "alchemy/Cloudflare";
342import * as Effect from "effect/Effect";
343+import { HttpServerRequest } from "effect/http/HttpServerRequest";
344import * as HttpServerResponse from "effect/http/HttpServerResponse";
345+import NotifyWorkflow from "./NotifyWorkflow.ts";
346
347export default Cloudflare.Worker(
348 "Worker",
349 { main: import.meta.url },
350 Effect.gen(function* () {
351+ const notifier = yield* NotifyWorkflow;
352
353 return {
354 fetch: Effect.gen(function* () {
355+ const request = yield* HttpServerRequest;
356+
357+ if (
358+ request.url.startsWith("/workflow/start/") &&
359+ request.method === "POST"
360+ ) {
361+ const roomId = request.url.split("/").pop()!;
362+ const instance = yield* notifier.create({
363+ params: {
364+ roomId,
365+ message: "hello from workflow",
366+ },
367+ });
368+ return yield* HttpServerResponse.json({ instanceId: instance.id });
369+ }
370+
371+ if (request.url.startsWith("/workflow/status/")) {
372+ const instanceId = request.url.split("/").pop()!;
373+ const instance = yield* notifier.get(instanceId);
374+ const status = yield* instance.status();
375+ return yield* HttpServerResponse.json(status);
376+ }
377+
378 return HttpServerResponse.text("Hello from my Worker!");
379 }),
380 };
381 }),
382);
383```
384
385`notifier.create({ params })` immediately returns an instance id —
386the workflow runs asynchronously on Cloudflare's side.
387`instance.status()` returns one of `"queued"`, `"running"`,
388`"paused"`, `"waiting"`, `"complete"`, `"errored"`, or
389`"terminated"` along with the `output` (what the body Effect
390returned) or `error`.
391
392Deploy:
393
394```sh
395bun alchemy deploy
396```
397
398Add a test that POSTs to `/workflow/start/:roomId`, then polls
399`/workflow/status/:instanceId` until the workflow reaches
400`complete`:
401
402```diff lang="typescript"
403// test/integ.test.ts
404import * as Cloudflare from "alchemy/Cloudflare";
405import * as Test from "alchemy/Test/Bun";
406import { expect } from "bun:test";
407import * as Effect from "effect/Effect";
408import * as Schedule from "effect/Schedule";
409import * as HttpClient from "effect/http/HttpClient";
410import Stack from "../alchemy.run.ts";
411
412const { test, beforeAll, deploy } = Test.make({
413 providers: Cloudflare.providers(),
414 state: Cloudflare.state(),
415});
416
417const stack = beforeAll(deploy(Stack));
418
419+test(
420+ "Notifier workflow completes within 60s",
421+ Effect.gen(function* () {
422+ const { url } = yield* stack;
423+ const roomId = `room-${Date.now()}`;
424+
425+ const start = yield* HttpClient.post(`${url}/workflow/start/${roomId}`);
426+ const { instanceId } = (yield* start.json) as { instanceId: string };
427+ expect(instanceId).toBeString();
428+
429+ const status = yield* HttpClient.get(
430+ `${url}/workflow/status/${instanceId}`,
431+ ).pipe(
432+ Effect.flatMap((res) => res.json),
433+ Effect.map((s) => s as { status: string }),
434+ Effect.repeat({
435+ schedule: Schedule.spaced("2 seconds"),
436+ until: (s) => s.status === "complete" || s.status === "errored",
437+ }),
438+ Effect.timeout("60 seconds"),
439+ );
440+
441+ expect(status.status).toBe("complete");
442+ }),
443+ { timeout: 120_000 },
444+);
445```
446
447```sh
448bun test test/integ.test.ts
449```
450
451The polling loop should see the workflow transition through
452`running` and finish in `complete` within ~5 seconds (most of which
453is the `sleep("cooldown", "2 seconds")` cooldown).
454
455## Test it locally
456
457The same test runs entirely on your machine — no separate
458workerd/vitest setup needed. Add `dev: true` to `Test.make` and the
459whole stack (Worker, Workflow, KV, Durable Objects) boots in a
460local workerd with real step semantics — tasks checkpoint, sleeps
461park, replays skip completed steps:
462
463```diff lang="typescript"
464const { test, beforeAll, deploy } = Test.make({
465 providers: Cloudflare.providers(),
466 state: Cloudflare.state(),
467+ dev: true,
468});
469```
470
471`stack.url` now points at `http://localhost:<port>`, and the
472start/poll assertions run unchanged against the local workflow. See
473[Test harness → dev](/testing/test-harness#dev) for the option's
474full semantics, and [Local development](/cloudflare/local-development)
475for what else runs locally.
476
477Your app now spans a Worker, a Vite frontend, Durable Objects,
478hibernatable WebSockets, a Container, and a Workflow — all
479deploying from CI thanks to [Part 5](/cloudflare/tutorial/part-5). From here,
480browse the [Concepts](/infrastructure-as-code/stack), [Guides](/cli), and
481[Providers](/providers) sections for whatever you need next.