Cloudflare Workflows
SkillCloud & infraBuild durable, long-running workflows on Cloudflare Workers with automatic retries, state persistence, and multi-step orchestration. Supports step.do, step.sleep, step.waitForEvent, and runs for hours to days.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Cloudflare Workflows skill
What this skill tells your AI
The instructions your AI receives, as published by ovachiever/droid-tings in skills/cloudflare-workflows/SKILL.md and read by ahel’s review.
Status: Production Ready ✅ Last Updated: 2025-10-22 Dependencies: cloudflare-worker-base (for Worker setup) Latest Versions: wrangler@4.44.0, @cloudflare/workers-types@4.20251014.0
Quick Start (10 Minutes)
1. Create a Workflow
Use the Cloudflare Workflows starter template:
npm create cloudflare@latest my-workflow -- --template cloudflare/workflows-starter --git --deploy false
cd my-workflow
What you get:
- WorkflowEntrypoint class template
- Worker to trigger workflows
- Complete wrangler.jsonc configuration
2. Understand the Basic Structure
src/index.ts:
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
type Env = {
MY_WORKFLOW: Workflow;
};
type Params = {
userId: string;
email: string;
};
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// Access params from event.payload
const { userId, email } = event.payload;
// Step 1: Do some work
const result = await step.do('process user', async () => {
return { processed: true, userId };
});
// Step 2: Wait before next action
await step.sleep('wait 1 hour', '1 hour');
// Step 3: Continue workflow
await step.do('send email', async () => {
// Send email logic
return { sent: true, email };
});
// Optional: return final state
return { completed: true, userId };
}
}
// Worker to trigger workflow
export default {
async fetch(req: Request, env: Env): Promise<Response> {
// Create new workflow instance
const instance = await env.MY_WORKFLOW.create({
params: { userId: '123', email: 'user@example.com' }
});
return Response.json({
id: instance.id,
status: await instance.status()
});
}
};
3. Configure Wrangler
wrangler.jsonc:
{
"name": "my-workflow",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
"workflows": [
{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow"
}
]
}
4. Deploy and Test
# Deploy workflow
npm run deploy
# Trigger workflow (visit in browser or curl)
curl https://my-workflow.<subdomain>.workers.dev/
# View workflow instances
npx wrangler workflows instances list my-workflow
# Check instance status
npx wrangler workflows instances describe my-workflow <instance-id>
WorkflowEntrypoint Class
Extend WorkflowEntrypoint
Every Workflow must extend WorkflowEntrypoint and implement a run() method:
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// Workflow steps here
}
}
Type Parameters:
Env- Environment bindings (KV, D1, R2, etc.)Params- Type of workflow parameters passed viaevent.payload
run() Method
async run(
event: WorkflowEvent<Params>,
step: WorkflowStep
): Promise<T | void>
Parameters:
event- Contains workflow metadata and payloadstep- Provides step methods (do, sleep, sleepUntil, waitForEvent)
Returns:
- Optional return value (must be serializable)
- Return value available via instance.status()
Example:
export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderParams> {
async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep) {
const { orderId, customerId } = event.payload;
// Access bindings via this.env
const order = await this.env.DB.prepare(
'SELECT * FROM orders WHERE id = ?'
).bind(orderId).first();
const result = await step.do('process payment', async () => {
// Payment processing
return { paid: true, amount: order.total };
});
// Return final state
return {
orderId,
status: 'completed',
paidAmount: result.amount
};
}
}
Step Methods
step.do() - Execute Work
step.do<T>(
name: string,
config?: WorkflowStepConfig,
callback: () => Promise<T>
): Promise<T>
OR (config is optional):
step.do<T>(
name: string,
callback: () => Promise<T>
): Promise<T>
Parameters:
name- Step name (for observability)config(optional) - Retry configurationcallback- Async function that does the work
Returns:
- The value returned from callback (must be serializable)
Example:
// Simple step
const files = await step.do('fetch files', async () => {
const response = await fetch('https://api.example.com/files');
return await response.json();
});
// Step with retry config
const result = await step.do(
'call payment API',
{
retries: {
limit: 10,
delay: '10 seconds',
backoff: 'exponential'
},
timeout: '5 minutes'
},
async () => {
const response = await fetch('https://payment-api.example.com/charge', {
method: 'POST',
body: JSON.stringify({ amount: 100 })
});
return await response.json();
}
);
CRITICAL - Serialization:
- Return value must be JSON serializable
- ✅ Allowed: string, number, boolean, Array, Object, null
- ❌ Forbidden: Function, Symbol, circular references, undefined
- Step will throw error if return value isn't serializable
step.sleep() - Relative Sleep
step.sleep(name: string, duration: WorkflowDuration): Promise<void>
Parameters:
name- Step nameduration- Number (milliseconds) or human-readable string
Accepted units:
"second"/"seconds""minute"/"minutes""hour"/"hours""day"/"days""week"/"weeks""month"/"months""year"/"years"
Examples:
// Sleep for 5 minutes
await step.sleep('wait 5 minutes', '5 minutes');
// Sleep for 1 hour
await step.sleep('hourly delay', '1 hour');
// Sleep for 2 days
await step.sleep('wait 2 days', '2 days');
// Sleep using milliseconds
await step.sleep('wait 30 seconds', 30000);
// Common pattern: schedule daily task
await step.do('send daily report', async () => {
// Send report
});
await step.sleep('wait until tomorrow', '1 day');
// Workflow continues next day
Priority:
- Workflows resuming from sleep take priority over new instances
- Ensures older workflows complete before new ones start
step.sleepUntil() - Sleep to Specific Date
step.sleepUntil(
name: string,
timestamp: Date | number
): Promise<void>
Parameters:
name- Step nametimestamp- Date object or UNIX timestamp (milliseconds)
Examples:
// Sleep until specific date
const launchDate = new Date('2025-12-25T00:00:00Z');
await step.sleepUntil('wait for launch', launchDate);
// Sleep until UNIX timestamp
const timestamp = Date.parse('24 Oct 2024 13:00:00 UTC');
await step.sleepUntil('wait until time', timestamp);
// Sleep until next Monday 9am UTC
const nextMonday = new Date();
nextMonday.setDate(nextMonday.getDate() + ((1 + 7 - nextMonday.getDay()) % 7 || 7));
nextMonday.setUTCHours(9, 0, 0, 0);
await step.sleepUntil('wait until Monday 9am', nextMonday);
// Schedule work at specific time
await step.do('prepare campaign', async () => {
// Prepare marketing campaign
});
const campaignLaunch = new Date('2025-11-01T12:00:00Z');
await step.sleepUntil('wait for campaign launch', campaignLaunch);
await step.do('launch campaign', async () => {
// Launch campaign
});
step.waitForEvent() - Wait for External Event
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)
Returns:
- The event payload sent via
instance.sendEvent()
Example:
export class PaymentWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// Create payment intent
await step.do('create payment intent', async () => {
// Call Stripe API
});
// Wait for webhook from Stripe (max 1 hour)
const webhookData = await step.waitForEvent<StripeWebhook>(
'wait for payment confirmation',
{ type: 'stripe-webhook', timeout: '1 hour' }
);
// Continue based on webhook
if (webhookData.status === 'succeeded') {
await step.do('fulfill order', async () => {
// Fulfill order
});
} else {
await step.do('handle failed payment', async () => {
// Handle failure
});
}
}
}
// Worker receives webhook and sends event to workflow
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.url.includes('/webhook/stripe')) {
const webhookData = await req.json();
// Get workflow instance by ID (stored when created)
const instance = await env.PAYMENT_WORKFLOW.get(instanceId);
// Send event to waiting workflow
await instance.sendEvent({
type: 'stripe-webhook',
payload: webhookData
});
return new Response('OK');
}
}
};
Timeout behavior:
- If timeout expires, throws error and workflow can retry or fail
- Wrap in try-catch if timeout should not fail workflow
try {
const event = await step.waitForEvent('wait for user input', {
type: 'user-submitted',
timeout: '10 minutes'
});
} catch (error) {
// Timeout occurred - handle gracefully
await step.do('send reminder', async () => {
// Send reminder to user
});
}
WorkflowStepConfig
Configure retry behavior for individual steps:
interface WorkflowStepConfig {
retries?: {
limit: number; // Max retry attempts (Infinity allowed)
delay: string | number; // Delay between retries
backoff?: 'constant' | 'linear' | 'exponential';
};
timeout?: string | number; // Max time per attempt
}
Default Configuration
If no config provided, Workflows uses:
{
retries: {
limit: 5,
delay: 10000, // 10 seconds
backoff: 'exponential'
},
timeout: '10 minutes'
}
Retry Examples
Constant Backoff (same delay each time):
await step.do(
'send email',
{
retries: {
limit: 3,
delay: '30 seconds',
backoff: 'constant' // Always wait 30 seconds
}
},
async () => {
// Send email
}
);
Linear Backoff (increasing delay):
await step.do(
'poll API',
{
retries: {
limit: 5,
delay: '1 minute',
backoff: 'linear' // 1m, 2m, 3m, 4m, 5m
}
},
async () => {
// Poll API
}
);
Exponential Backoff (recommended for most cases):
await step.do(
'call rate-limited API',
{
retries: {
limit: 10,
delay: '10 seconds',
backoff: 'exponential' // 10s, 20s, 40s, 80s, 160s, ...
},
timeout: '5 minutes'
},
async () => {
// API call
}
);
Unlimited Retries:
await step.do(
'critical operation',
{
retries: {
limit: Infinity, // Retry forever
delay: '1 minute',
backoff: 'exponential'
}
},
async () => {
// Operation that must succeed eventually
}
);
No Retries:
await step.do(
'non-idempotent operation',
{
retries: {
limit: 0 // Fail immediately on error
}
},
async () => {
// One-time operation
}
);
Error Handling
NonRetryableError
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:
- ✅ Authentication/authorization failures
- ✅ Invalid input that won't change
- ✅ Resource doesn't exist (404)
- ✅ Validation errors
- ❌ Network failures (should retry)
- ❌ Rate limits (should retry with backoff)
- ❌ Temporary service outages (should retry)
Catch Errors to Continue Workflow
Prevent entire workflow from failing by catching step errors:
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// Critical step - workflow fails if this fails
await step.do('process payment', async () => {
// Payment processing
});
// Optional step - workflow continues even if it fails
try {
await step.do('send confirmation email', async () => {
// Email sending
});
} catch (error) {
console.log(`Email failed: ${error.message}`);
// Do cleanup or alternative action
await step.do('log email failure', async () => {
await this.env.DB.prepare(
'INSERT INTO failed_emails (user_id, error) VALUES (?, ?)'
).bind(event.payload.userId, error.message).run();
});
}
// Workflow continues
await step.do('update order status', async () => {
// Update status
});
}
}
Pattern: Graceful degradation:
// Try primary service, fall back to secondary
let result;
try {
result = await step.do('call primary API', async () => {
return await callPrimaryAPI();
});
} catch (error) {
console.log('Primary API failed, trying backup');
result = await step.do('call backup API', async () => {
return await callBackupAPI();
});
}
Triggering Workflows
From Workers
Configure binding in wrangler.jsonc:
{
"name": "trigger-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
"workflows": [
{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow",
"script_name": "workflow-worker" // If workflow is in different Worker
}
]
}
Trigger from Worker:
type Env = {
MY_WORKFLOW: Workflow;
};
export default {
async fetch(req: Request, env: Env): Promise<Response> {
// Create new workflow instance
const instance = await env.MY_WORKFLOW.create({
params: {
userId: '123',
email: 'user@example.com'
}
});
// Return instance ID
return Response.json({
id: instance.id,
status: await instance.status()
});
}
};
Get Instance Status
// Get instance by ID
const instance = await env.MY_WORKFLOW.get(instanceId);
// Get status
const status = await instance.status();
console.log(status);
// {
// status: 'running' | 'complete' | 'errored' | 'queued' | 'unknown',
// error: string | null,
// output: any // Return value from run() if complete
// }
Send Events to Running Instance
// Get instance
const instance = await env.MY_WORKFLOW.get(instanceId);
// Send event (will be received by step.waitForEvent)
await instance.sendEvent({
type: 'user-action',
payload: { action: 'approved' }
});
Pause and Resume
// Pause instance
await instance.pause();
// Resume instance
await instance.resume();
// Terminate instance
await instance.terminate();
Workflow Patterns
Pattern 1: Long-Running Process
export class VideoProcessingWorkflow extends WorkflowEntrypoint<Env, VideoParams> {
async run(event: WorkflowEvent<VideoParams>, step: WorkflowStep) {
const { videoId } = event.payload;
// Step 1: Upload to processing service
const uploadResult = await step.do('upload video', async () => {
const video = await this.env.MY_BUCKET.get(`videos/${videoId}`);
const response = await fetch('https://processor.example.com/upload', {
method: 'POST',
body: video?.body
});
return await response.json();
});
// Step 2: Wait for processing (could take hours)
await step.sleep('wait for initial processing', '10 minutes');
// Step 3: Poll for completion
let processed = false;
let attempts = 0;
while (!processed && attempts < 20) {
const status = await step.do(`check status attempt ${attempts}`, async () => {
const response = await fetch(
`https://processor.example.com/status/${uploadResult.jobId}`
);
return await response.json();
});
if (status.complete) {
processed = true;
} else {
attempts++;
await step.sleep(`wait before retry ${attempts}`, '5 minutes');
}
}
// Step 4: Download processed video
await step.do('download processed video', async () => {
const response = await fetch(uploadResult.downloadUrl);
const processed = await response.blob();
await this.env.MY_BUCKET.put(`processed/${videoId}`, processed);
});
return { videoId, status: 'complete' };
}
}
Pattern 2: Event-Driven Approval Flow
export class ApprovalWorkflow extends WorkflowEntrypoint<Env, ApprovalParams> {
async run(event: WorkflowEvent<ApprovalParams>, step: WorkflowStep) {
const { requestId, requesterId } = event.payload;
// Step 1: Create approval request
await step.do('create approval request', async () => {
await this.env.DB.prepare(
'INSERT INTO approvals (id, requester_id, status) VALUES (?, ?, ?)'
).bind(requestId, requesterId, 'pending').run();
});
// Step 2: Send notification to approvers
await step.do('notify approvers', async () => {
await sendNotification(requestId);
});
// Step 3: Wait for approval (max 7 days)
let approvalEvent;
try {
approvalEvent = await step.waitForEvent<ApprovalEvent>(
'wait for approval decision',
{ type: 'approval-decision', timeout: '7 days' }
);
} catch (error) {
// Timeout - auto-reject
await step.do('auto-reject due to timeout', async () => {
await this.env.DB.prepare(
'UPDATE approvals SET status = ? WHERE id = ?'
).bind('rejected', requestId).run();
});
return { requestId, status: 'rejected', reason: 'timeout' };
}
// Step 4: Process decision
await step.do('process approval decision', async () => {
await this.env.DB.prepare(
'UPDATE approvals SET status = ?, approver_id = ? WHERE id = ?'
).bind(approvalEvent.approved ? 'approved' : 'rejected', approvalEvent.approverId, requestId).run();
});
// Step 5: Execute approved action (if approved)
if (approvalEvent.approved) {
await step.do('execute approved action', async () => {
// Execute the action
});
}
return { requestId, status: approvalEvent.approved ? 'approved' : 'rejected' };
}
}
Pattern 3: Scheduled Workflow
export class DailyReportWorkflow extends WorkflowEntrypoint<Env, ReportParams> {
async run(event: WorkflowEvent<ReportParams>, step: WorkflowStep) {
// Calculate next 9am UTC
const now = new Date();
const tomorrow9am = new Date();
tomorrow9am.setUTCDate(tomorrow9am.getUTCDate() + 1);
tomorrow9am.setUTCHours(9, 0, 0, 0);
// Sleep until tomorrow 9am
await step.sleepUntil('wait until 9am tomorrow', tomorrow9am);
// Generate report
const report = await step.do('generate daily report', async () => {
const results = await this.env.DB.prepare(
'SELECT * FROM metrics WHERE date = ?'
).bind(now.toISOString().split('T')[0]).all();
return {
date: now.toISOString().split('T')[0],
metrics: results.results
};
});
// Send report
await step.do('send report', async () => {
await sendEmail({
to: event.payload.recipients,
subject: `Daily Report - ${report.date}`,
body: formatReport(report.metrics)
});
});
return { sent: true, date: report.date };
}
}
Pattern 4: Workflow Chaining
export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderParams> {
async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep) {
const { orderId } = event.payload;
// Step 1: Process payment
const paymentResult = await step.do('process payment', async () => {
return await processPayment(orderId);
});
// Step 2: Trigger fulfillment workflow
const fulfillmentInstance = await step.do('start fulfillment', async () => {
return await this.env.FULFILLMENT_WORKFLOW.create({
params: {
orderId,
paymentId: paymentResult.id
}
});
});
// Step 3: Wait for fulfillment to complete
await step.sleep('wait for fulfillment', '5 minutes');
// Step 4: Check fulfillment status
const fulfillmentStatus = await step.do('check fulfillment', async () => {
const instance = await this.env.FULFILLMENT_WORKFLOW.get(fulfillmentInstance.id);
return await instance.status();
});
if (fulfillmentStatus.status === 'complete') {
// Step 5: Send confirmation
await step.do('send order confirmation', async () => {
await sendConfirmation(orderId);
});
}
return { orderId, status: 'complete' };
}
}
Wrangler Commands
List Workflow Instances
# List all instances of a workflow
npx wrangler workflows instances list my-workflow
# Filter by status
npx wrangler workflows instances list my-workflow --status running
npx wrangler workflows instances list my-workflow --status complete
npx wrangler workflows instances list my-workflow --status errored
Describe Instance
# Get detailed info about specific instance
npx wrangler workflows instances describe my-workflow <instance-id>
# Output shows:
# - Current status (running/complete/errored)
# - Each step with start/end times
# - Step outputs
# - Retry history
# - Any errors
# - Sleep state (if sleeping)
Trigger Workflow (Development)
# Deploy workflow
npx wrangler deploy
# Trigger via HTTP (if Worker is set up to trigger)
curl https://my-workflow.<subdomain>.workers.dev/
State Persistence
What Can Be Persisted
Workflows automatically persist state returned from step.do():
✅ Serializable Types:
- Primitives:
string,number,boolean,null - Arrays:
[1, 2, 3],['a', 'b', 'c'] - Objects:
{ key: 'value' },{ nested: { data: true } } - Nested structures:
{ users: [{ id: 1, name: 'Alice' }] }
❌ Non-Serializable Types:
- Functions:
() => {} - Symbols:
Symbol('key') - Circular references:
const obj = {}; obj.self = obj; - undefined (use null instead)
- Class instances (serialize to plain objects)
Example - Correct Serialization:
// ✅ Good - all values serializable
const result = await step.do('fetch data', async () => {
return {
users: [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false }
],
timestamp: Date.now(),
metadata: null
};
});
// ❌ Bad - contains function
const bad = await step.do('bad example', async () => {
return {
data: [1, 2, 3],
transform: (x) => x * 2 // ❌ Function not serializable
};
});
// This will throw an error!
Access State Across Steps
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 52
- Forks
- 5
- Last commit
- Nov 2025
ahel review
S4info
community integration — published by ovachiever, not cloudflare
Automated review, not a security audit. Ruleset v1.
Advanced
- Catalog kind
- skill
- Gateway key
cloudflare-workflows-ovachiever- Source
- github.com/ovachiever/droid-tings