vercel-workflow
Original:🇺🇸 English
Translated
Best practices for using Vercel Workflow DevKit. Use when creating, modifying, or debugging workflows, steps, hooks, webhooks, or any durable function using the Workflow DevKit. Ensures proper usage of directives, error handling, serialization, streaming, and workflow patterns.
1installs
Sourcevictor-teles/skills
Added on
NPX Install
npx skill4agent add victor-teles/skills vercel-workflowTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →Workflow Best Practices
Enforces proper usage of Vercel Workflow DevKit patterns for durable, resumable workflows.
Core Concepts
Directives
Two fundamental directives define execution context:
"use workflow"typescript
export async function processOrder(orderId: string) {
'use workflow';
const order = await fetchOrder(orderId); // Step
await sleep('1h'); // Suspend
return await chargePayment(order); // Step
}"use step"typescript
async function fetchOrder(orderId: string) {
'use step';
// Full Node.js access: database, APIs, file I/O
return await db.orders.findUnique({ where: { id: orderId } });
}Critical Rules:
- Workflow functions must be deterministic - same inputs always produce same outputs
- Workflows run in a sandboxed environment without Node.js API access
- Steps have full runtime access and automatic retries on failure
- All parameters must be serializable (no functions, class instances, closures)
Execution Model
Workflows suspend and resume through:
- Step calls - Workflow yields while step executes
- - Pause for duration without consuming resources
sleep() - Hooks/Webhooks - Wait for external events
During replay, workflows re-execute using cached step results from the event log.
Structure Patterns
Organization
workflows/{feature-name}/
├── index.ts # Workflow orchestration
├── steps/ # Step functions
│ ├── {action}.ts
│ └── ...
└── hooks/ # Hook definitions
└── {event}.tsWorkflow Function
typescript
import { sleep, createHook } from 'workflow';
import { processData } from './steps/process-data';
import { sendEmail } from './steps/send-email';
export async function myWorkflow(userId: string) {
'use workflow';
const result = await processData(userId);
await sleep('5m');
await sendEmail({ userId, result });
return { status: 'completed', result };
}Rules:
- ONE workflow export per file (main entry point)
- Orchestrate steps - don't do work directly
- Use language primitives: ,
Promise.all,for...oftry/catch - NO Node.js APIs: ,
fs,http,cryptoprocess - NO side effects: database calls, API requests, mutations
- Parameters and returns must be serializable
Step Functions
typescript
type ProcessDataArgs = {
userId: string;
options?: { retry?: boolean };
};
export async function processData(params: ProcessDataArgs) {
'use step';
// Full Node.js access
const user = await db.users.findUnique({ where: { id: params.userId } });
const result = await externalApi.process(user);
return { processed: true, data: result };
}Rules:
- ONE step per file is preferred for clarity
- Use typed parameters (object or single value)
- Return serializable values only
- Mutations happen here - not in workflows
- Can throw errors for automatic retry
- Use for idempotency keys
getStepMetadata()
Error Handling
Automatic Retries
Steps retry automatically (default: 3 attempts):
typescript
async function fetchData(url: string) {
'use step';
// Throws Error - will retry
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
return response.json();
}Fatal Errors (No Retry)
typescript
import { FatalError } from 'workflow';
async function validateUser(userId: string) {
'use step';
if (!userId) {
// Don't retry invalid input
throw new FatalError('User ID is required');
}
return await db.users.findUnique({ where: { id: userId } });
}Retryable with Delay
typescript
import { RetryableError } from 'workflow';
async function callRateLimitedApi() {
'use step';
const response = await fetch('https://api.example.com');
if (response.status === 429) {
// Retry after 10 seconds
throw new RetryableError('Rate limited', { delay: '10s' });
}
return response.json();
}Workflow Error Handling
typescript
export async function resilientWorkflow(orderId: string) {
'use workflow';
try {
const order = await fetchOrder(orderId);
await processPayment(order);
} catch (error) {
// Log and handle at workflow level
await logError({ orderId, error: String(error) });
throw error; // Workflow will fail
}
}Serialization
Allowed Types
Primitives, objects, arrays, Date, URL, Headers, Request, Response, ReadableStream, WritableStream.
Pass-by-Value
Parameters are copied, not referenced:
typescript
// ❌ WRONG - mutations not visible
export async function badWorkflow() {
'use workflow';
let counter = 0;
await updateCounter(counter);
console.log(counter); // Still 0!
}
async function updateCounter(count: number) {
'use step';
count++; // Only mutates the copy
}typescript
// ✅ CORRECT - return modified values
export async function goodWorkflow() {
'use workflow';
let counter = 0;
counter = await updateCounter(counter);
console.log(counter); // 1
}
async function updateCounter(count: number) {
'use step';
return count + 1;
}Forbidden Types
NO functions, class instances, symbols, WeakMaps, closures:
typescript
// ❌ WRONG
async function badStep(callback: () => void) {
'use step';
callback(); // ERROR: Cannot serialize functions
}
// ✅ CORRECT - use configuration
type Config = { shouldLog: boolean };
async function goodStep(config: Config) {
'use step';
if (config.shouldLog) console.log('Done');
}Hooks & Webhooks
Type-Safe Hooks
typescript
import { defineHook } from 'workflow';
import { z } from 'zod';
const approvalHook = defineHook({
schema: z.object({
approved: z.boolean(),
approvedBy: z.string(),
comment: z.string(),
}),
});
export async function documentWorkflow(docId: string) {
'use workflow';
const hook = approvalHook.create({
token: `approval:${docId}`,
});
const result = await hook;
return result.approved ? 'approved' : 'rejected';
}Iterating Over Events
typescript
import { createHook } from 'workflow';
export async function monitoringWorkflow(channelId: string) {
'use workflow';
const hook = createHook<{ message: string }>({
token: `messages:${channelId}`,
});
for await (const event of hook) {
await processMessage(event.message);
if (event.message === 'stop') break;
}
}Webhooks
typescript
import { createWebhook } from 'workflow';
export async function paymentWorkflow(orderId: string) {
'use workflow';
const webhook = createWebhook({
respondWith: new Response('Payment received', { status: 200 }),
});
await sendPaymentLink({ orderId, webhookUrl: webhook.url });
const request = await webhook;
const payload = await request.json();
return { paid: true, transactionId: payload.id };
}Streaming
Writing to Streams (Steps Only)
typescript
import { getWritable } from 'workflow';
export async function progressWorkflow() {
'use workflow';
const writable = getWritable<{ progress: number }>();
await processWithProgress(writable);
await finalizeStream(writable);
}
async function processWithProgress(writable: WritableStream) {
'use step';
const writer = writable.getWriter();
try {
for (let i = 0; i <= 100; i += 10) {
await writer.write({ progress: i });
await new Promise(resolve => setTimeout(resolve, 100));
}
} finally {
writer.releaseLock();
}
}
async function finalizeStream(writable: WritableStream) {
'use step';
await writable.close();
}Critical: Workflows can GET streams but NOT interact with them. Steps must do all writing/closing.
Namespaced Streams
typescript
export async function multiStreamWorkflow() {
'use workflow';
const defaultStream = getWritable();
const logStream = getWritable({ namespace: 'logs' });
await writeToStreams(defaultStream, logStream);
}
async function writeToStreams(
defaultStream: WritableStream,
logStream: WritableStream
) {
'use step';
const writer1 = defaultStream.getWriter();
const writer2 = logStream.getWriter();
try {
await writer1.write({ data: 'main' });
await writer2.write({ log: 'processing' });
} finally {
writer1.releaseLock();
writer2.releaseLock();
}
}Common Patterns
Sequential Steps
typescript
export async function sequentialWorkflow(data: unknown) {
'use workflow';
const validated = await validateData(data);
const processed = await processData(validated);
const stored = await storeData(processed);
return stored;
}Parallel Steps
typescript
export async function parallelWorkflow(userId: string) {
'use workflow';
const [user, orders, payments] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchPayments(userId),
]);
return { user, orders, payments };
}Conditional Steps
typescript
export async function conditionalWorkflow(orderId: string) {
'use workflow';
const order = await fetchOrder(orderId);
if (order.isPaid) {
await fulfillOrder(order);
} else {
await sendPaymentReminder(order);
}
}Loops with Steps
typescript
export async function batchWorkflow(items: string[]) {
'use workflow';
for (const item of items) {
await processItem(item);
}
return { processed: items.length };
}Timeout Pattern
typescript
import { sleep } from 'workflow';
export async function timeoutWorkflow(taskId: string) {
'use workflow';
const result = await Promise.race([
processTask(taskId),
sleep('30s').then(() => 'timeout' as const),
]);
if (result === 'timeout') {
throw new Error('Task timed out after 30 seconds');
}
return result;
}Rollback Pattern
typescript
export async function rollbackWorkflow(orderId: string) {
'use workflow';
const rollbacks: Array<() => Promise<void>> = [];
try {
await reserveInventory(orderId);
rollbacks.push(() => releaseInventory(orderId));
await chargePayment(orderId);
rollbacks.push(() => refundPayment(orderId));
await fulfillOrder(orderId);
} catch (error) {
// Execute rollbacks in reverse order
for (const rollback of rollbacks.reverse()) {
await rollback();
}
throw error;
}
}Idempotency
Using Step IDs
typescript
import { getStepMetadata } from 'workflow';
async function chargeUser(userId: string, amount: number) {
'use step';
const { stepId } = getStepMetadata();
return await stripe.charges.create(
{ amount, currency: 'usd', customer: userId },
{ idempotencyKey: `charge:${stepId}` }
);
}Rules:
- Always use for external API idempotency
stepId - is stable across retries
stepId - Never use attempt numbers or timestamps
Testing Workflows
typescript
import { start } from 'workflow/api';
import { myWorkflow } from './workflows/my-workflow';
// Start workflow
const run = await start(myWorkflow, ['arg1']);
// Check status
console.log(await run.status); // 'running' | 'completed' | 'failed'
// Wait for completion
const result = await run.returnValue;
// Stream output
const stream = run.readable;Anti-Patterns
❌ Direct Node.js API in Workflow
typescript
export async function badWorkflow() {
'use workflow';
// ERROR: fs not available in workflow context
const data = fs.readFileSync('file.txt');
}❌ Non-Deterministic Logic
typescript
export async function badWorkflow() {
'use workflow';
// ERROR: Date.now() will change on replay
if (Date.now() > someTimestamp) { /* ... */ }
// ERROR: Math.random() will change on replay
if (Math.random() > 0.5) { /* ... */ }
}❌ Mutating Parameters
typescript
export async function badWorkflow(data: { count: number }) {
'use workflow';
await incrementCount(data);
console.log(data.count); // Still original value!
}
async function incrementCount(data: { count: number }) {
'use step';
data.count++; // Only mutates the copy
}❌ Stream Interaction in Workflow
typescript
export async function badWorkflow() {
'use workflow';
const writable = getWritable();
const writer = writable.getWriter(); // ERROR!
await writer.write('data'); // ERROR!
}❌ Missing Directive
typescript
// ERROR: No "use step" - won't be retried
async function fetchData() {
return await db.query('SELECT * FROM users');
}Quick Reference
Workflow Functions:
- Orchestrate steps
- Must be deterministic
- No Node.js APIs
- Sandboxed environment
Step Functions:
- Execute work
- Full Node.js access
- Automatic retries
- Can throw errors
Serialization:
- Pass-by-value (copy)
- Return modified values
- No functions/closures
Error Handling:
- - Retry automatically
Error - - No retry
FatalError - - Retry with delay
RetryableError
Streaming:
- Get stream in workflow
- Interact in steps only
- Always release locks
- Close when done
Hooks:
- Use for type safety
defineHook - Custom tokens for determinism
- Iterate with
for await...of
Idempotency:
- Use for keys
stepId - Apply to external APIs
- Steps are already idempotent