WE
← Back to Documentation

Creating Workflow Steps

Learn how to build your own reusable workflow steps

Step Anatomy

A workflow step is a TypeScript function with the 'use step' directive. Here's a complete example:

steps/my-custom-step.ts
import { FatalError } from 'workflow';

// 1. Define parameter interface
interface MyCustomStepParams {
  requiredParam: string;
  optionalParam?: number;
}

/**
 * 2. Add JSDoc documentation
 * 
 * Describe what your step does, its parameters,
 * and what it returns.
 */
export async function myCustomStep({
  requiredParam,
  optionalParam = 10,
}: MyCustomStepParams) {
  'use step'; // 3. Add the directive

  // 4. Validate inputs
  if (!requiredParam) {
    throw new FatalError('requiredParam is required');
  }

  // 5. Validate environment variables
  const apiKey = process.env.MY_API_KEY;
  if (!apiKey) {
    throw new FatalError('MY_API_KEY not configured');
  }

  // 6. Implement your logic
  try {
    const response = await fetch('https://api.example.com', {
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (!response.ok) {
      // 7. Handle non-retryable errors
      if (response.status === 401) {
        throw new FatalError('Invalid API key');
      }
      
      // 8. Let transient errors retry
      throw new Error(`API error: ${response.status}`);
    }

    const data = await response.json();

    // 9. Return the result
    return {
      success: true,
      data,
    };
  } catch (error: any) {
    if (error instanceof FatalError) {
      throw error;
    }
    // Network errors will retry automatically
    throw error;
  }
}

Best Practices

1. Use FatalError for Non-Retryable Failures

Throw FatalError when retrying won't help:

  • • Missing or invalid configuration
  • • Authentication failures (401, 403)
  • • Invalid input data (400, 422)
  • • Resource not found (404)

2. Let Transient Errors Retry

Regular errors will automatically retry. Use them for:

  • • Network timeouts
  • • Rate limits (429)
  • • Server errors (500, 502, 503)
  • • Temporary service unavailability

3. Validate Early

Check all required parameters and environment variables at the start of your function. This prevents wasting time on doomed operations.

4. Use TypeScript

Always define interfaces for parameters and return types. This provides excellent IDE support and catches errors at compile time.

5. Document Everything

Use JSDoc comments to explain what your step does, what parameters it accepts, and what it returns. Include examples in your documentation.

6. Keep Steps Focused

Each step should do one thing well. If you're doing multiple operations, consider breaking them into separate steps that can be composed in a workflow.

Testing Your Step

Test your step in a simple workflow:

workflows/test-my-step.ts
import { myCustomStep } from '../steps/my-custom-step';

export async function testMyStep() {
  'use workflow';

  const result = await myCustomStep({
    requiredParam: 'test-value',
  });

  console.log('Step result:', result);
  return result;
}

Common Patterns

Timeout Handling

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);

try {
  const response = await fetch(url, {
    signal: controller.signal,
  });
  clearTimeout(timeout);
  // ...
} catch (error: any) {
  clearTimeout(timeout);
  if (error.name === 'AbortError') {
    throw new Error('Request timeout'); // Will retry
  }
  throw error;
}

Rate Limit Handling

if (response.status === 429) {
  // Let it retry automatically
  throw new Error('Rate limited');
}

Data Validation with Zod

import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  age: z.number().min(0),
});

try {
  const validated = schema.parse(data);
} catch (error) {
  throw new FatalError('Invalid data');
}

Next Steps

Once you've created a step you're happy with, consider contributing it to the registry so others can benefit from your work!