skills/victor-teles/skills/vercel-workflow

vercel-workflow

SKILL.md

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" - Marks orchestration functions that coordinate steps:

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" - Marks atomic operations with full runtime access:

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:

  1. Step calls - Workflow yields while step executes
  2. sleep() - Pause for duration without consuming resources
  3. 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}.ts

Workflow Function

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...of, try/catch
  • NO Node.js APIs: fs, http, crypto, process
  • NO side effects: database calls, API requests, mutations
  • Parameters and returns must be serializable

Step Functions

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 getStepMetadata() for idempotency keys

Error Handling

Automatic Retries

Steps retry automatically (default: 3 attempts):

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)

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

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

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:

// ❌ 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
}
// ✅ 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:

// ❌ 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

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

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

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)

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

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

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

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

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

export async function batchWorkflow(items: string[]) {
  'use workflow';
  
  for (const item of items) {
    await processItem(item);
  }
  
  return { processed: items.length };
}

Timeout Pattern

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

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

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 stepId for external API idempotency
  • stepId is stable across retries
  • Never use attempt numbers or timestamps

Testing Workflows

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

export async function badWorkflow() {
  'use workflow';
  
  // ERROR: fs not available in workflow context
  const data = fs.readFileSync('file.txt');
}

❌ Non-Deterministic Logic

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

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

export async function badWorkflow() {
  'use workflow';
  
  const writable = getWritable();
  const writer = writable.getWriter(); // ERROR!
  await writer.write('data'); // ERROR!
}

❌ Missing Directive

// 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:

  • Error - Retry automatically
  • FatalError - No retry
  • RetryableError - Retry with delay

Streaming:

  • Get stream in workflow
  • Interact in steps only
  • Always release locks
  • Close when done

Hooks:

  • Use defineHook for type safety
  • Custom tokens for determinism
  • Iterate with for await...of

Idempotency:

  • Use stepId for keys
  • Apply to external APIs
  • Steps are already idempotent
Weekly Installs
12
First Seen
Jan 27, 2026
Installed on
codex10
cursor10
opencode9
gemini-cli9
github-copilot9
claude-code7