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
@@ -1,128 +1,516 @@
1
1
  ---
2
2
  title: Sandbox
3
- description: Orchestrate Vercel Sandbox lifecycle -- creation, code execution, snapshotting -- inside durable workflows.
3
+ description: Model one Vercel Sandbox per workflow run durable, idle-efficient, and not bound by the 5-hour sandbox hard cap.
4
4
  type: guide
5
- summary: Use workflow steps to provision sandboxes, run code, and manage sandbox lifecycle with automatic cleanup on failure.
5
+ summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by runId so one logical session can run effectively forever.
6
6
  related:
7
7
  - /docs/ai/defining-tools
8
8
  - /docs/foundations/errors-and-retries
9
- - /docs/api-reference/workflow-ai/durable-agent
9
+ - /docs/cookbook/common-patterns/scheduling
10
+ - /docs/cookbook/agent-patterns/durable-agent
10
11
  ---
11
12
 
12
- [Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package implements first-class support for the Workflow SDK -- the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `destroy`, etc.) implicitly run as steps. This means you can interact with sandboxes directly inside workflow functions without wrapping each operation in a separate `"use step"` function.
13
+ [Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function.
13
14
 
14
- ## What It Enables
15
+ ## Why Workflow + Sandbox
15
16
 
16
- - **Durable sandbox sessions** -- Sandbox provisioning and teardown survive cold starts
17
- - **Automatic cleanup** -- Saga-style compensation ensures sandboxes are destroyed on failure
18
- - **Multi-step code execution** -- Run a sequence of commands in the same sandbox with each step logged
19
- - **Agent-driven sandboxes** -- Give your DurableAgent a tool that spins up sandboxes on demand
17
+ A sandbox alone gets you an isolated VM. A workflow around it gets you a **durable controller** for that VM's entire lifetime:
20
18
 
21
- ## When to Use
19
+ - **One workflow run = one sandbox session.** The `runId` is the only state you need to persist on the client. Close the tab, come back a week later, POST the same `runId` and you're back in the same session.
20
+ - **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer — when idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. Next command → spin a new sandbox from the snapshot with filesystem, installed packages, and git history intact.
21
+ - **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure.
22
+ - **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy.
22
23
 
23
- Use this integration when your workflow needs to:
24
+ ## Use Case: Coding Agents
24
25
 
25
- - Execute user-provided or AI-generated code safely
26
- - Run multi-step build/test pipelines in isolated environments
27
- - Provision temporary environments for interactive sessions
28
- - Snapshot sandbox state between steps for reproducibility
26
+ This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return.
29
27
 
30
- ## Sandbox Lifecycle in a Workflow
28
+ Most coding-agent workloads look like this:
31
29
 
32
- Because `@vercel/sandbox` methods are implicit steps, each call is automatically persisted to the event log. If a failure occurs partway through, the workflow replays from where it left off.
30
+ - User sends a task agent plans, reads files, runs shell commands, commits.
31
+ - User walks away mid-run → agent keeps going, eventually goes idle waiting for input.
32
+ - User comes back days later → same branch, same filesystem, same conversation history.
33
+
34
+ Without durable workflows you'd need a separate state store for the agent loop, a separate job queue for retries, a separate scheduler for idle cleanup, and bespoke reconnection logic. With the pattern below, all of it is one file.
35
+
36
+ ## Quickstart: One-shot Pipeline
37
+
38
+ Before the full session pattern, the simplest shape. Each sandbox method is an implicit step, so the event log records every command and the workflow replays from the last completed call on restart.
33
39
 
34
40
  ```typescript title="workflows/sandbox-pipeline.ts" lineNumbers
35
41
  import { Sandbox } from "@vercel/sandbox";
36
42
 
37
- export async function sandboxPipeline(input: {
38
- template: string;
39
- commands: string[];
40
- }) {
43
+ export async function sandboxPipeline(input: { commands: string[] }) {
41
44
  "use workflow";
42
45
 
43
- const sandbox = await Sandbox.create({ template: input.template }); // [!code highlight]
46
+ const sandbox = await Sandbox.create({ runtime: "node22" }); // [!code highlight]
44
47
 
45
48
  try {
46
49
  const results = [];
47
50
  for (const command of input.commands) {
48
- const result = await sandbox.runCommand(command); // [!code highlight]
49
- results.push(result);
51
+ const result = await sandbox.runCommand({ // [!code highlight]
52
+ cmd: "bash",
53
+ args: ["-c", command],
54
+ });
55
+ results.push({
56
+ command,
57
+ exitCode: result.exitCode,
58
+ stdout: await result.stdout(),
59
+ stderr: await result.stderr(),
60
+ });
50
61
  }
51
62
  return { status: "completed", results };
52
- } catch (error) {
53
- await sandbox.destroy(); // [!code highlight]
54
- throw error;
63
+ } finally {
64
+ await sandbox.stop(); // [!code highlight]
55
65
  }
56
66
  }
57
67
  ```
58
68
 
59
- ## Sandbox as an Agent Tool
69
+ ## Session Pattern: Persistent Sandbox Beyond the Hard Cap
60
70
 
61
- Give a DurableAgent the ability to create and use sandboxes. The agent decides when to spin up a sandbox, what code to run, and when to tear it down. Since sandbox methods are implicit steps, the tool execute functions can call them directly.
71
+ One workflow run owns a sandbox for its whole lifetime. The workflow's loop does two jobs simultaneously:
62
72
 
63
- ```typescript title="workflows/code-agent.ts" lineNumbers
64
- import { Sandbox } from "@vercel/sandbox";
65
- import { DurableAgent } from "@workflow/ai/agent";
66
- import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
67
- import { getWritable } from "workflow";
68
- import z from "zod/v4";
73
+ 1. **Command pipeline** — await a hook, run the next user command, stream output, loop.
74
+ 2. **Sandbox lifecycle** race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap).
75
+
76
+ When the timer wins:
77
+
78
+ - **Idle** `sandbox.snapshot()` and wait indefinitely for the next command. No compute while asleep.
79
+ - **Near sandbox hard cap** → `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session appears continuous; the underlying VM just rotated.
80
+
81
+ The only way out is an explicit `/destroy` command.
82
+
83
+ <Tabs items={['Workflow', 'API Routes', 'Client']}>
84
+
85
+ <Tab value="Workflow">
86
+
87
+ ```typescript title="workflows/sandbox-session.ts" lineNumbers
88
+ import { defineHook, sleep, getWritable, getWorkflowMetadata } from "workflow";
89
+ import { Sandbox, type Snapshot } from "@vercel/sandbox";
90
+ import { z } from "zod";
91
+
92
+ export const commandHook = defineHook({ // [!code highlight]
93
+ schema: z.object({ command: z.string() }),
94
+ });
95
+
96
+ const RUNTIME = "node22";
97
+ const HIBERNATE_AFTER_MS = 30 * 60_000; // 30 min idle → hibernate
98
+ const SANDBOX_TIMEOUT_MS = 5 * 60 * 60_000; // sandbox hard cap (5h)
99
+ const REFRESH_SAFETY_MS = 5 * 60_000; // refresh 5 min before the cap
69
100
 
70
- export async function codeAgent(messages: UIMessage[]) {
101
+ export type SandboxEvent =
102
+ | {
103
+ type: "created";
104
+ sandboxId: string;
105
+ runtime: string;
106
+ startedAt: number;
107
+ sandboxExpiresAt: number;
108
+ hibernateAfterMs: number;
109
+ }
110
+ | {
111
+ type: "status";
112
+ state:
113
+ | "active"
114
+ | "hibernating"
115
+ | "hibernated"
116
+ | "resuming"
117
+ | "refreshing"
118
+ | "destroyed";
119
+ at: number;
120
+ sandboxId?: string;
121
+ sandboxExpiresAt?: number;
122
+ snapshotId?: string;
123
+ }
124
+ | { type: "activity"; at: number }
125
+ | { type: "command_start"; id: string; command: string; at: number }
126
+ | { type: "command_output"; id: string; stream: "stdout" | "stderr"; data: string }
127
+ | { type: "command_end"; id: string; exitCode: number | null; durationMs: number }
128
+ | { type: "result"; status: "destroyed"; durationMs: number };
129
+
130
+ async function emit(event: SandboxEvent) {
131
+ "use step";
132
+ const writer = getWritable<SandboxEvent>().getWriter();
133
+ try {
134
+ await writer.write(event);
135
+ } finally {
136
+ writer.releaseLock();
137
+ }
138
+ }
139
+
140
+ async function runCommandAndStream(sandbox: Sandbox, id: string, command: string) {
141
+ "use step";
142
+ const writer = getWritable<SandboxEvent>().getWriter();
143
+ const startedAt = Date.now();
144
+ try {
145
+ await writer.write({ type: "command_start", id, command, at: startedAt });
146
+ const result = await sandbox.runCommand({ cmd: "bash", args: ["-c", command] });
147
+ const stdout = await result.stdout();
148
+ if (stdout) await writer.write({ type: "command_output", id, stream: "stdout", data: stdout });
149
+ const stderr = await result.stderr();
150
+ if (stderr) await writer.write({ type: "command_output", id, stream: "stderr", data: stderr });
151
+ await writer.write({
152
+ type: "command_end", id,
153
+ exitCode: result.exitCode,
154
+ durationMs: Date.now() - startedAt,
155
+ });
156
+ } finally {
157
+ writer.releaseLock();
158
+ }
159
+ }
160
+
161
+ export async function sandboxSessionWorkflow() {
71
162
  "use workflow";
72
163
 
73
- let activeSandbox: Sandbox | null = null;
74
-
75
- const agent = new DurableAgent({
76
- model: "anthropic/claude-sonnet-4-20250514",
77
- instructions:
78
- "You are a coding assistant. You can create sandboxes to run code. " +
79
- "Always create a sandbox first, then execute code in it. " +
80
- "Clean up the sandbox when you are done.",
81
- tools: {
82
- createSandbox: {
83
- description: "Create an isolated sandbox environment for running code",
84
- inputSchema: z.object({
85
- template: z.string().describe("The sandbox template (e.g., 'node', 'python')"),
86
- }),
87
- execute: async ({ template }) => {
88
- activeSandbox = await Sandbox.create({ template }); // [!code highlight]
89
- return { sandboxId: activeSandbox.id };
90
- },
91
- },
92
- executeCode: {
93
- description: "Execute a command in the active sandbox",
94
- inputSchema: z.object({
95
- command: z.string().describe("The command to execute"),
96
- }),
97
- execute: async ({ command }) => {
98
- if (!activeSandbox) throw new Error("No active sandbox");
99
- return activeSandbox.runCommand(command); // [!code highlight]
100
- },
101
- },
102
- cleanupSandbox: {
103
- description: "Destroy the active sandbox when finished",
104
- inputSchema: z.object({}),
105
- execute: async () => {
106
- if (!activeSandbox) throw new Error("No active sandbox");
107
- await activeSandbox.destroy(); // [!code highlight]
108
- activeSandbox = null;
109
- return { cleaned: true };
164
+ const { workflowRunId } = getWorkflowMetadata();
165
+ // Create the hook once, outside the loop — reusing the same token from inside // [!code highlight]
166
+ // the loop would throw HookConflictError. // [!code highlight]
167
+ const hook = commandHook.create({ token: workflowRunId });
168
+
169
+ const startedAt = Date.now();
170
+
171
+ let sandbox: Sandbox = await Sandbox.create({
172
+ runtime: RUNTIME,
173
+ timeout: SANDBOX_TIMEOUT_MS,
174
+ });
175
+ let sandboxCreatedAt = Date.now();
176
+ let sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
177
+
178
+ await emit({
179
+ type: "created",
180
+ sandboxId: sandbox.sandboxId,
181
+ runtime: RUNTIME,
182
+ startedAt,
183
+ sandboxExpiresAt,
184
+ hibernateAfterMs: HIBERNATE_AFTER_MS,
185
+ });
186
+ await emit({
187
+ type: "status", state: "active", at: Date.now(),
188
+ sandboxId: sandbox.sandboxId, sandboxExpiresAt,
189
+ });
190
+
191
+ let snapshot: Snapshot | null = null;
192
+ let hibernated = false;
193
+ let lastActivityAt = startedAt;
194
+ let counter = 0;
195
+ let destroyed = false;
196
+
197
+ try {
198
+ while (!destroyed) {
199
+ if (hibernated && snapshot) {
200
+ // While hibernated, the VM is already stopped. Just wait for the next
201
+ // command — no idle timer, no compute cost.
202
+ const payload = await hook;
203
+ if (payload.command === "/destroy") { destroyed = true; break; }
204
+
205
+ await emit({ type: "status", state: "resuming", at: Date.now() });
206
+ sandbox = await Sandbox.create({ // [!code highlight]
207
+ source: { type: "snapshot", snapshotId: snapshot.snapshotId }, // [!code highlight]
208
+ timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
209
+ });
210
+ sandboxCreatedAt = Date.now();
211
+ sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
212
+ hibernated = false;
213
+ snapshot = null;
214
+ await emit({
215
+ type: "status", state: "active", at: Date.now(),
216
+ sandboxId: sandbox.sandboxId, sandboxExpiresAt,
217
+ });
218
+
219
+ counter += 1;
220
+ await runCommandAndStream(sandbox, `cmd-${counter}`, payload.command);
221
+ lastActivityAt = Date.now();
222
+ await emit({ type: "activity", at: lastActivityAt });
223
+ continue;
224
+ }
225
+
226
+ // Active — wake at whichever comes first: idle-deadline or refresh-deadline.
227
+ const idleDeadline = lastActivityAt + HIBERNATE_AFTER_MS;
228
+ const refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS;
229
+ const wakeAt = Math.min(idleDeadline, refreshDeadline);
230
+ const sleepMs = Math.max(0, wakeAt - Date.now());
231
+
232
+ const outcome = await Promise.race([ // [!code highlight]
233
+ hook.then((p) => ({ type: "command" as const, command: p.command })),
234
+ sleep(`${sleepMs}ms`).then(() => ({ type: "timer" as const })),
235
+ ]);
236
+
237
+ if (outcome.type === "timer") {
238
+ const nearExpiry = Date.now() >= refreshDeadline;
239
+
240
+ if (nearExpiry) {
241
+ // Proactive refresh — snapshot and immediately recreate so the
242
+ // session outlives the sandbox hard cap.
243
+ await emit({ type: "status", state: "refreshing", at: Date.now() });
244
+ const snap = await sandbox.snapshot(); // [!code highlight]
245
+ sandbox = await Sandbox.create({ // [!code highlight]
246
+ source: { type: "snapshot", snapshotId: snap.snapshotId }, // [!code highlight]
247
+ timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
248
+ });
249
+ sandboxCreatedAt = Date.now();
250
+ sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
251
+ await emit({
252
+ type: "status", state: "active", at: Date.now(),
253
+ sandboxId: sandbox.sandboxId, sandboxExpiresAt,
254
+ snapshotId: snap.snapshotId,
255
+ });
256
+ lastActivityAt = Date.now();
257
+ } else {
258
+ // Idle — snapshot and hibernate indefinitely.
259
+ await emit({ type: "status", state: "hibernating", at: Date.now() });
260
+ snapshot = await sandbox.snapshot(); // [!code highlight]
261
+ hibernated = true;
262
+ await emit({
263
+ type: "status", state: "hibernated", at: Date.now(),
264
+ snapshotId: snapshot.snapshotId,
265
+ });
266
+ }
267
+ continue;
268
+ }
269
+
270
+ if (outcome.command === "/destroy") { destroyed = true; break; }
271
+
272
+ counter += 1;
273
+ await runCommandAndStream(sandbox, `cmd-${counter}`, outcome.command);
274
+ lastActivityAt = Date.now();
275
+ await emit({ type: "activity", at: lastActivityAt });
276
+ }
277
+ } finally {
278
+ if (!hibernated) {
279
+ try {
280
+ if (sandbox.status === "running") await sandbox.stop();
281
+ } catch { /* best-effort */ }
282
+ }
283
+ await emit({ type: "status", state: "destroyed", at: Date.now() });
284
+ await emit({
285
+ type: "result",
286
+ status: "destroyed",
287
+ durationMs: Date.now() - startedAt,
288
+ });
289
+ }
290
+ }
291
+ ```
292
+
293
+ </Tab>
294
+
295
+ <Tab value="API Routes">
296
+
297
+ Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
298
+
299
+ ```typescript title="app/api/sandbox/start/route.ts" lineNumbers
300
+ import { start, getRun } from "workflow/api";
301
+ import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
302
+
303
+ export async function POST(req: Request) {
304
+ let body: { runId?: string } = {};
305
+ try {
306
+ const text = await req.text();
307
+ if (text) body = JSON.parse(text);
308
+ } catch { /* ignore malformed body */ }
309
+
310
+ // Reconnect path: if the client sends a known runId, stream the durable
311
+ // event log from the beginning so the UI can rehydrate.
312
+ if (body.runId) {
313
+ const run = getRun(body.runId);
314
+ if (await run.exists) { // [!code highlight]
315
+ const readable = run.getReadable({ startIndex: 0 }); // [!code highlight]
316
+ return new Response(readable.pipeThrough(ndjson()), {
317
+ headers: {
318
+ "Content-Type": "application/x-ndjson",
319
+ "x-workflow-run-id": body.runId,
320
+ "x-workflow-reconnected": "true",
321
+ "Cache-Control": "no-cache, no-transform",
110
322
  },
111
- },
323
+ });
324
+ }
325
+ // Stale runId — fall through to start fresh.
326
+ }
327
+
328
+ const run = await start(sandboxSessionWorkflow, []);
329
+ return new Response(run.readable.pipeThrough(ndjson()), {
330
+ headers: {
331
+ "Content-Type": "application/x-ndjson",
332
+ "x-workflow-run-id": run.runId,
333
+ "Cache-Control": "no-cache, no-transform",
112
334
  },
113
335
  });
336
+ }
114
337
 
115
- const result = await agent.stream({
116
- messages: await convertToModelMessages(messages),
117
- writable: getWritable<UIMessageChunk>(),
338
+ function ndjson<T>() {
339
+ return new TransformStream<T, string>({
340
+ transform(chunk, controller) {
341
+ controller.enqueue(JSON.stringify(chunk) + "\n");
342
+ },
118
343
  });
344
+ }
345
+ ```
119
346
 
120
- return { messages: result.messages };
347
+ ```typescript title="app/api/sandbox/command/route.ts" lineNumbers
348
+ import { commandHook } from "@/workflows/sandbox-session";
349
+
350
+ export async function POST(req: Request) {
351
+ const { runId, command } = (await req.json()) as { runId?: string; command?: string };
352
+
353
+ if (!runId || typeof command !== "string") {
354
+ return Response.json({ error: "runId and command are required" }, { status: 400 });
355
+ }
356
+
357
+ try {
358
+ await commandHook.resume(runId, { command }); // [!code highlight]
359
+ return Response.json({ ok: true });
360
+ } catch (error) {
361
+ const msg = error instanceof Error ? error.message.toLowerCase() : "";
362
+ if (msg.includes("not found") || msg.includes("expired")) {
363
+ return Response.json({ ok: false, note: "session expired" }, { status: 410 });
364
+ }
365
+ throw error;
366
+ }
121
367
  }
122
368
  ```
123
369
 
124
- ## Saga Pattern for Cleanup
370
+ </Tab>
371
+
372
+ <Tab value="Client">
373
+
374
+ On mount, if a `runId` is stashed in `localStorage`, reconnect to the existing run. Otherwise start fresh. Commands are POSTed to `/command` — output lands on the `/start` stream.
375
+
376
+ ```tsx title="components/sandbox-runner.tsx" lineNumbers
377
+ "use client";
378
+
379
+ import { useCallback, useEffect, useRef, useState } from "react";
380
+ import type { SandboxEvent } from "@/workflows/sandbox-session";
381
+
382
+ const RUN_ID_KEY = "sandbox.runId";
383
+
384
+ export function SandboxRunner() {
385
+ const [events, setEvents] = useState<SandboxEvent[]>([]);
386
+ const runIdRef = useRef<string | null>(null);
387
+ const didReconnectRef = useRef(false);
388
+
389
+ const consume = useCallback(async (res: Response) => {
390
+ if (!res.ok || !res.body) return;
391
+ runIdRef.current = res.headers.get("x-workflow-run-id");
392
+ if (runIdRef.current) {
393
+ localStorage.setItem(RUN_ID_KEY, runIdRef.current); // [!code highlight]
394
+ }
395
+
396
+ const reader = res.body.getReader();
397
+ const decoder = new TextDecoder();
398
+ let buffer = "";
399
+
400
+ while (true) {
401
+ const { done, value } = await reader.read();
402
+ if (done) break;
403
+ buffer += decoder.decode(value, { stream: true });
404
+ const lines = buffer.split("\n");
405
+ buffer = lines.pop() ?? "";
406
+ for (const line of lines) {
407
+ if (!line.trim()) continue;
408
+ try {
409
+ setEvents((prev) => [...prev, JSON.parse(line) as SandboxEvent]);
410
+ } catch { /* malformed line */ }
411
+ }
412
+ }
413
+ }, []);
414
+
415
+ const openStream = useCallback(
416
+ async (runId?: string) => {
417
+ setEvents([]);
418
+ const res = await fetch("/api/sandbox/start", {
419
+ method: "POST",
420
+ headers: runId ? { "Content-Type": "application/json" } : undefined,
421
+ body: runId ? JSON.stringify({ runId }) : undefined,
422
+ });
423
+ await consume(res);
424
+ },
425
+ [consume]
426
+ );
427
+
428
+ // Auto-reconnect on mount if a runId is stashed.
429
+ useEffect(() => {
430
+ if (didReconnectRef.current) return;
431
+ didReconnectRef.current = true;
432
+ const stored = localStorage.getItem(RUN_ID_KEY);
433
+ if (stored) openStream(stored); // [!code highlight]
434
+ }, [openStream]);
435
+
436
+ const start = useCallback(() => {
437
+ localStorage.removeItem(RUN_ID_KEY);
438
+ runIdRef.current = null;
439
+ openStream();
440
+ }, [openStream]);
441
+
442
+ const sendCommand = useCallback(async (command: string) => {
443
+ if (!runIdRef.current) return;
444
+ const res = await fetch("/api/sandbox/command", {
445
+ method: "POST",
446
+ headers: { "Content-Type": "application/json" },
447
+ body: JSON.stringify({ runId: runIdRef.current, command }),
448
+ });
449
+ if (res.status === 410) localStorage.removeItem(RUN_ID_KEY);
450
+ }, []);
451
+
452
+ const destroy = useCallback(async () => {
453
+ await sendCommand("/destroy");
454
+ localStorage.removeItem(RUN_ID_KEY);
455
+ }, [sendCommand]);
456
+
457
+ // Render events as a terminal-style log. Drive UI state from `status` events
458
+ // (active / hibernating / hibernated / resuming / refreshing / destroyed).
459
+ return null;
460
+ }
461
+ ```
462
+
463
+ </Tab>
464
+
465
+ </Tabs>
466
+
467
+ ## How It Works
468
+
469
+ 1. **One workflow = one session.** The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember.
470
+ 2. **Hook created once.** `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`.
471
+ 3. **Two timer branches.** The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone — no timer, no compute.
472
+ 4. **Proactive refresh.** `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS`. Hitting this triggers a snapshot + immediate new sandbox from that snapshot, rolling over the hard cap without user intervention.
473
+ 5. **`sandbox.snapshot()` stops the VM.** It's documented as part of the snapshot process — don't call `stop()` separately.
474
+ 6. **Resume = new sandbox.** `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a fresh VM from the snapshot. The new sandbox has a different `sandboxId`; filesystem, installed packages, and git history are preserved.
475
+ 7. **Reconnect by runId.** `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, who rebuilds UI state from the replay.
476
+ 8. **Exit only on `/destroy`.** The workflow loop has no hard deadline of its own. Individual sandboxes time out; the session doesn't.
477
+
478
+ ## Pitfalls
479
+
480
+ ### `sandbox.stop()` is terminal
481
+
482
+ A stopped sandbox cannot be restarted — you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later.
483
+
484
+ ### `snapshot()` already stops the VM
485
+
486
+ Calling `stop()` after `snapshot()` either errors or is a no-op depending on timing. Snapshot takes care of it.
487
+
488
+ ### New `sandboxId` after resume and refresh
489
+
490
+ Both `resuming` (idle → command) and `refreshing` (near-hard-cap rotation) create a new sandbox with a new `sandboxId`. Emit it on the subsequent `status: "active"` event and have the UI read from there, not from the initial `created` event.
491
+
492
+ ### Keep the refresh margin generous
493
+
494
+ `snapshot()` + `Sandbox.create({ source })` takes real time (typically tens of seconds). If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; 5 minutes is comfortable.
495
+
496
+ ### Don't call `writable.close()` inside a workflow function
497
+
498
+ Stream closure must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. The runtime closes the underlying writable when the workflow returns.
499
+
500
+ ### Handle stale `runId` gracefully
501
+
502
+ Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.
503
+
504
+ ### Keep the hook outside the loop
505
+
506
+ Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
125
507
 
126
- Combine sandbox orchestration with the [saga pattern](/docs/cookbook/common-patterns/saga) to ensure sandboxes are always cleaned up, even when a step in the middle of your pipeline fails.
508
+ ## Key APIs
127
509
 
128
- The example above uses a try/catch around the command execution loop. For more complex pipelines with multiple resources (sandbox + database + external API), push compensation functions onto a stack as shown in the [saga recipe](/docs/cookbook/common-patterns/saga).
510
+ - [`Sandbox.create`](https://vercel.com/docs/sandbox) provision a VM (runtime, source, timeout)
511
+ - [`sandbox.runCommand`](https://vercel.com/docs/sandbox) — execute a command; implicit step
512
+ - [`sandbox.snapshot`](https://vercel.com/docs/sandbox) — save state and stop the VM; returns `Snapshot`
513
+ - [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for user commands
514
+ - [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
515
+ - [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
516
+ - [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "title": "Cookbook",
3
3
  "defaultOpen": true,
4
- "pages": ["common-patterns", "agent-patterns", "integrations", "advanced"]
4
+ "pages": ["agent-patterns", "common-patterns", "integrations", "advanced"]
5
5
  }
@@ -17,9 +17,6 @@ Workflow programming can be a slight shift from how you traditionally write real
17
17
  <Card href="/docs/foundations/starting-workflows" title="Starting Workflows">
18
18
  Trigger workflows and track their execution using the `start()` function.
19
19
  </Card>
20
- <Card href="/docs/foundations/common-patterns" title="Common Patterns">
21
- Common patterns useful in workflows.
22
- </Card>
23
20
  <Card href="/docs/foundations/errors-and-retries" title="Errors & Retrying">
24
21
  Types of errors and how retrying work in workflows.
25
22
  </Card>
@@ -3,7 +3,6 @@
3
3
  "pages": [
4
4
  "workflows-and-steps",
5
5
  "starting-workflows",
6
- "common-patterns",
7
6
  "errors-and-retries",
8
7
  "hooks",
9
8
  "streaming",
@@ -185,7 +185,7 @@ async function doublePoint(point: Point) {
185
185
 
186
186
  2. **`WORKFLOW_DESERIALIZE`**: A static method that receives the serialized data and returns a new class instance
187
187
 
188
- 3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization
188
+ 3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time — no manual registration step is required
189
189
 
190
190
  ### Requirements
191
191
 
@@ -213,6 +213,6 @@ export async function GET(request: Request) {
213
213
 
214
214
  Now that you understand how to start workflows and track their execution:
215
215
 
216
- - Learn about [Common Patterns](/docs/foundations/common-patterns) for organizing complex workflows
216
+ - Browse the [Cookbook](/cookbook) for copy-paste recipes covering composition, scheduling, timeouts, and more
217
217
  - Explore [Errors & Retrying](/docs/foundations/errors-and-retries) to handle failures gracefully
218
218
  - Check the [`start()` API Reference](/docs/api-reference/workflow-api/start) for complete details