Build durable workflows with Cloudflare Workflows (GA April 2025). Features step.do, step.sleep, waitForEvent, Vitest testing, automatic retries, and state persistence for long-running tasks. Use when: creating workflows, implementing retries, or troubleshooting NonRetryableError, I/O context, serialization errors.
/plugin marketplace add jezweb/claude-skills/plugin install jezweb-tooling-skills@jezweb/claude-skillsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
README.mdreferences/common-issues.mdreferences/workflow-patterns.mdrules/cloudflare-workflows.mdtemplates/basic-workflow.tstemplates/scheduled-workflow.tstemplates/worker-trigger.tstemplates/workflow-with-events.tstemplates/workflow-with-retries.tstemplates/wrangler-workflows-config.jsoncStatus: Production Ready ✅ (GA since April 2025) Last Updated: 2026-01-09 Dependencies: cloudflare-worker-base (for Worker setup) Latest Versions: wrangler@4.58.0, @cloudflare/workers-types@4.20260109.0
Recent Updates (2025):
# 1. Scaffold project
npm create cloudflare@latest my-workflow -- --template cloudflare/workflows-starter --git --deploy false
cd my-workflow
# 2. Configure wrangler.jsonc
{
"name": "my-workflow",
"main": "src/index.ts",
"compatibility_date": "2025-11-25",
"workflows": [{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow"
}]
}
# 3. Create workflow (src/index.ts)
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const result = await step.do('process', async () => { /* work */ });
await step.sleep('wait', '1 hour');
await step.do('continue', async () => { /* more work */ });
}
}
# 4. Deploy and test
npm run deploy
npx wrangler workflows instances list my-workflow
CRITICAL: Extends WorkflowEntrypoint, implements run() with step methods, bindings in wrangler.jsonc
step.do<T>(name: string, config?: WorkflowStepConfig, callback: () => Promise<T>): Promise<T>
Parameters:
name - Step name (for observability)config (optional) - Retry configuration (retries, timeout, backoff)callback - Async function that does the workReturns: Value from callback (must be serializable)
Example:
const result = await step.do('call API', { retries: { limit: 10, delay: '10s', backoff: 'exponential' }, timeout: '5 min' }, async () => {
return await fetch('https://api.example.com/data').then(r => r.json());
});
CRITICAL - Serialization:
step.sleep(name: string, duration: WorkflowDuration): Promise<void>
Parameters:
name - Step nameduration - Number (ms) or string: "second", "minute", "hour", "day", "week", "month", "year" (plural forms accepted)Examples:
await step.sleep('wait 5 minutes', '5 minutes');
await step.sleep('wait 1 hour', '1 hour');
await step.sleep('wait 2 days', '2 days');
await step.sleep('wait 30 seconds', 30000); // milliseconds
Note: Resuming workflows take priority over new instances. Sleeps don't count toward step limits.
step.sleepUntil(name: string, timestamp: Date | number): Promise<void>
Parameters:
name - Step nametimestamp - Date object or UNIX timestamp (milliseconds)Examples:
await step.sleepUntil('wait for launch', new Date('2025-12-25T00:00:00Z'));
await step.sleepUntil('wait until time', Date.parse('24 Oct 2024 13:00:00 UTC'));
step.waitForEvent<T>(name: string, options: { type: string; timeout?: string | number }): Promise<T>
Parameters:
name - Step nameoptions.type - Event type to matchoptions.timeout (optional) - Max wait time (default: 24 hours, max: 30 days)Returns: Event payload sent via instance.sendEvent()
Example:
export class PaymentWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
await step.do('create payment', async () => { /* Stripe API */ });
const webhookData = await step.waitForEvent<StripeWebhook>(
'wait for payment confirmation',
{ type: 'stripe-webhook', timeout: '1 hour' }
);
if (webhookData.status === 'succeeded') {
await step.do('fulfill order', async () => { /* fulfill */ });
}
}
}
// Worker sends event to workflow
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.url.includes('/webhook/stripe')) {
const instance = await env.PAYMENT_WORKFLOW.get(instanceId);
await instance.sendEvent({ type: 'stripe-webhook', payload: await req.json() });
return new Response('OK');
}
}
};
Timeout handling:
try {
const event = await step.waitForEvent('wait for user', { type: 'user-submitted', timeout: '10 minutes' });
} catch (error) {
await step.do('send reminder', async () => { /* reminder */ });
}
interface WorkflowStepConfig {
retries?: {
limit: number; // Max attempts (Infinity allowed)
delay: string | number; // Delay between retries
backoff?: 'constant' | 'linear' | 'exponential';
};
timeout?: string | number; // Max time per attempt
}
Default: { retries: { limit: 5, delay: 10000, backoff: 'exponential' }, timeout: '10 minutes' }
Backoff Examples:
// Constant: 30s, 30s, 30s
{ retries: { limit: 3, delay: '30 seconds', backoff: 'constant' } }
// Linear: 1m, 2m, 3m, 4m, 5m
{ retries: { limit: 5, delay: '1 minute', backoff: 'linear' } }
// Exponential (recommended): 10s, 20s, 40s, 80s, 160s
{ retries: { limit: 10, delay: '10 seconds', backoff: 'exponential' }, timeout: '5 minutes' }
// Unlimited retries
{ retries: { limit: Infinity, delay: '1 minute', backoff: 'exponential' } }
// No retries
{ retries: { limit: 0 } }
Force workflow to fail immediately without retrying:
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
import { NonRetryableError } from 'cloudflare:workflows';
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
await step.do('validate input', async () => {
if (!event.payload.userId) {
throw new NonRetryableError('userId is required');
}
// Validate user exists
const user = await this.env.DB.prepare(
'SELECT * FROM users WHERE id = ?'
).bind(event.payload.userId).first();
if (!user) {
// Terminal error - retrying won't help
throw new NonRetryableError('User not found');
}
return user;
});
}
}
When to use NonRetryableError:
Prevent workflow failure by catching optional step errors:
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
await step.do('process payment', async () => { /* critical */ });
try {
await step.do('send email', async () => { /* optional */ });
} catch (error) {
await step.do('log failure', async () => {
await this.env.DB.prepare('INSERT INTO failed_emails VALUES (?, ?)').bind(event.payload.userId, error.message).run();
});
}
await step.do('update status', async () => { /* continues */ });
}
}
Graceful Degradation:
let result;
try {
result = await step.do('call primary API', async () => await callPrimaryAPI());
} catch {
result = await step.do('call backup API', async () => await callBackupAPI());
}
Configure binding (wrangler.jsonc):
{
"workflows": [{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow",
"script_name": "workflow-worker" // If workflow in different Worker
}]
}
Trigger from Worker:
const instance = await env.MY_WORKFLOW.create({ params: { userId: '123' } });
return Response.json({ id: instance.id, status: await instance.status() });
Instance Management:
const instance = await env.MY_WORKFLOW.get(instanceId);
const status = await instance.status(); // { status: 'running'|'complete'|'errored'|'queued', error, output }
await instance.sendEvent({ type: 'user-action', payload: { action: 'approved' } });
await instance.pause();
await instance.resume();
await instance.terminate();
Workflows automatically persist state returned from step.do():
✅ Serializable:
string, number, boolean, null❌ Non-Serializable:
Example:
// ✅ Good
const result = await step.do('fetch data', async () => ({
users: [{ id: 1, name: 'Alice' }],
timestamp: Date.now(),
metadata: null
}));
// ❌ Bad - function not serializable
const bad = await step.do('bad', async () => ({ data: [1, 2, 3], transform: (x) => x * 2 })); // Throws error!
Access State Across Steps:
const userData = await step.do('fetch user', async () => ({ id: 123, email: 'user@example.com' }));
const orderData = await step.do('create order', async () => ({ userId: userData.id, orderId: 'ORD-456' }));
await step.do('send email', async () => sendEmail({ to: userData.email, subject: `Order ${orderData.orderId}` }));
Workflows automatically track:
Access via Cloudflare dashboard:
Metrics include:
const instance = await env.MY_WORKFLOW.get(instanceId);
const status = await instance.status();
console.log(status);
// {
// status: 'complete' | 'running' | 'errored' | 'queued' | 'waiting' | 'unknown',
// error: string | null,
// output: { userId: '123', status: 'processed' }
// }
CPU Time Configuration (2025):
// wrangler.jsonc
{ "limits": { "cpu_ms": 300000 } } // 5 minutes max (default: 30 seconds)
| Feature | Workers Free | Workers Paid |
|---|---|---|
| Max steps per workflow | 1,024 | 1,024 |
| Max state per step | 1 MiB | 1 MiB |
| Max state per instance | 100 MB | 1 GB |
| Max event payload size | 1 MiB | 1 MiB |
| Max sleep/sleepUntil duration | 365 days | 365 days |
| Max waitForEvent timeout | 365 days | 365 days |
| CPU time per step | 10 ms | 30 sec (default), 5 min (max) |
| Duration (wall clock) per step | Unlimited | Unlimited |
| Max workflow executions | 100,000/day | Unlimited |
| Concurrent instances | 25 | 10,000 (Oct 2025, up from 4,500) |
| Instance creation rate | 100/second | 100/second (Oct 2025, 10x faster) |
| Max queued instances | 100,000 | 1,000,000 |
| Max subrequests per instance | 50/request | 1,000/request |
| Retention (completed state) | 3 days | 30 days |
| Max Workflow name length | 64 chars | 64 chars |
| Max instance ID length | 100 chars | 100 chars |
CRITICAL Notes:
step.sleep() and step.sleepUntil() do NOT count toward 1,024 step limitwrangler.jsonc: { "limits": { "cpu_ms": 300000 } } (5 min max)Requires Workers Paid plan ($5/month)
Workflow Executions:
What counts as a step execution:
step.do() callstep.sleep(), step.sleepUntil(), step.waitForEvent() do NOT countCost examples:
Cause: Trying to use I/O objects created in one request context from another request handler
Solution: Always perform I/O within step.do() callbacks
// ❌ Bad - I/O outside step
const response = await fetch('https://api.example.com/data');
const data = await response.json();
await step.do('use data', async () => {
// Using data from outside step's I/O context
return data; // This will fail!
});
// ✅ Good - I/O inside step
const data = await step.do('fetch data', async () => {
const response = await fetch('https://api.example.com/data');
return await response.json(); // ✅ Correct
});
Known Issue: Throwing NonRetryableError with empty message in dev mode causes retries, but works correctly in production
Workaround: Always provide a message to NonRetryableError
// ❌ May retry in dev
throw new NonRetryableError();
// ✅ Works consistently
throw new NonRetryableError('User not found');
Source: workers-sdk#10113
Workflows support full testing integration via cloudflare:test module.
npm install -D vitest@latest @cloudflare/vitest-pool-workers@latest
vitest.config.ts:
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({ test: { poolOptions: { workers: { miniflare: { bindings: { MY_WORKFLOW: { scriptName: 'workflow' } } } } } } });
import { env, introspectWorkflowInstance } from 'cloudflare:test';
it('should complete workflow', async () => {
const instance = await introspectWorkflowInstance(env.MY_WORKFLOW, 'test-123');
try {
await instance.modify(async (m) => {
await m.disableSleeps(); // Skip all sleeps
await m.mockStepResult({ name: 'fetch data' }, { users: [{ id: 1 }] }); // Mock step result
await m.mockEvent({ type: 'approval', payload: { approved: true } }); // Send mock event
await m.mockStepError({ name: 'call API' }, new Error('Network timeout'), 1); // Force error once
});
await env.MY_WORKFLOW.create({ id: 'test-123' });
await expect(instance.waitForStatus('complete')).resolves.not.toThrow();
} finally {
await instance.dispose(); // Cleanup
}
});
disableSleeps(steps?) - Skip sleeps instantlymockStepResult(step, result) - Mock step.do() resultmockStepError(step, error, times?) - Force step.do() to throwmockEvent(event) - Send mock event to step.waitForEvent()forceStepTimeout(step, times?) - Force step.do() timeoutforceEventTimeout(step) - Force step.waitForEvent() timeoutOfficial Docs: https://developers.cloudflare.com/workers/testing/vitest-integration/
mcp__cloudflare-docs__search_cloudflare_documentation for latest docsLast Updated: 2025-11-25 Version: 1.0.0 Maintainer: Jeremy Dawes | jeremy@jezweb.net
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.