Skip to content
Back to skills

Output Dev Step Function

ASecurity

Create step functions in steps.ts for Output SDK workflows. Use when implementing I/O operations, error handling, HTTP requests, or LLM calls.

  • 440 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 27, 2026
data-aitypescriptgoapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 2, 2026

npx -y skills add growthxai/output --skill output-dev-step-function --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Output Dev Step Function?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Output Dev Step Function
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/growthxai-output-dev-step-function/badge)](https://www.skillsdirectory.com/skills/growthxai-output-dev-step-function)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: output-dev-step-function
description: Create step functions in steps.ts for Output SDK workflows. Use when implementing I/O operations, error handling, HTTP requests, or LLM calls.
allowed-tools: [Read, Write, Edit]
---

# Creating Step Functions

## Overview

This skill documents how to create step functions in `steps.ts` for Output SDK workflows. Steps are where all I/O operations happen - HTTP requests, LLM calls, database operations, file system access, etc.

## When to Use This Skill

- Implementing I/O operations for a workflow
- Adding HTTP client integrations
- Implementing LLM-powered steps
- Handling errors with FatalError and ValidationError
- Creating reusable step components

## File Organization

### Option 1: Flat File (Default)

For smaller workflows, use a single `steps.ts` file:

```
src/workflows/{workflow-name}/
├── workflow.ts
├── steps.ts         # All steps in one file
├── types.ts
└── ...
```

### Option 2: Folder-Based (Large workflows)

For larger workflows with many steps, use a `steps/` folder:

```
src/workflows/{workflow-name}/
├── workflow.ts
├── steps/           # Steps split into individual files
│   ├── fetch_data.ts
│   ├── process.ts
│   └── validate.ts
├── types.ts
└── ...
```

## Component Location Rules

**Important**: `step()` calls MUST be in files containing 'steps' in the path:
- `src/workflows/my_workflow/steps.ts` ✓
- `src/workflows/my_workflow/steps/fetch_data.ts` ✓
- `src/shared/steps/common_steps.ts` ✓
- `src/workflows/my_workflow/helpers.ts` ✗ (cannot contain step() calls)

## Activity Isolation Constraints

Steps are Temporal activities with strict import rules to ensure deterministic replay.

### Steps CAN import from:
- Local workflow files: `./utils.js`, `./types.js`, `./helpers.js`
- Local subdirectories: `./clients/pokeapi.js`, `./lib/helpers.js`
- Shared utilities: `../../shared/utils/*.js`
- Shared clients: `../../shared/clients/*.js`
- Shared services: `../../shared/services/*.js`

### Steps CANNOT import:
- Other step files (even shared steps - workflows import those)
- Evaluator files
- Workflow files

**Example of WRONG imports:**
```typescript
// WRONG - steps cannot import other steps
import { otherStep } from '../../shared/steps/other.js'; // ✗
import { anotherStep } from './other_steps.js'; // ✗
```

## Critical Import Patterns

### Core Imports

```typescript
// CORRECT - Import from @outputai/core
import { step, z, FatalError, ValidationError } from '@outputai/core';

// WRONG - Never import z from zod
import { z } from 'zod';
```

### HTTP Client Import

```typescript
// CORRECT - Use @outputai/http wrapper
import { createKyClient } from '@outputai/http';

// WRONG - Never use axios directly
import axios from 'axios';
```

**Related Skill**: `output-error-http-client`

### LLM Client Import

```typescript
// CORRECT - Use @outputai/llm wrapper
import { generateText, aiSdk } from '@outputai/llm';

// WRONG - Never call LLM providers directly
import OpenAI from 'openai';
```

### ES Module Imports

All imports MUST use `.js` extension:

```typescript
// CORRECT
import { InputSchema, OutputSchema } from './types.js';
import { GeminiService } from '../../shared/clients/gemini_client.js';

// WRONG - Missing .js extension
import { InputSchema, OutputSchema } from './types';
```

## Basic Structure

```typescript
import { step, z, FatalError, ValidationError } from '@outputai/core';
import { createKyClient } from '@outputai/http';
import { generateText, aiSdk } from '@outputai/llm';

import { StepInputSchema, StepOutputSchema } from './types.js';

export const myStep = step( {
  name: 'myStep',
  description: 'Description of what this step does',
  inputSchema: StepInputSchema,
  outputSchema: StepOutputSchema,
  fn: async input => {
    // Implementation with I/O operations
    return { /* output matching outputSchema */ };
  }
} );
```

## Required Properties

### name (string)
Unique identifier for the step. Use camelCase.

```typescript
name: 'generateImageIdeas'
```

### description (string)
Human-readable description of the step's purpose.

```typescript
description: 'Generate creative infographic prompt ideas using Claude'
```

### inputSchema (Zod schema)
Schema for validating step input. Define in `types.ts` and import.

```typescript
inputSchema: z.object( {
  content: z.string(),
  numberOfIdeas: z.number()
} )
```

### outputSchema (Zod schema)
Schema for validating step output. Define in `types.ts` and import.

```typescript
outputSchema: z.array( z.string() )
```

### fn (async function)
The step execution function. This is where I/O operations happen.

```typescript
fn: async input => {
  const result = await someExternalService( input );
  return result;
}
```

## HTTP Client Usage

### Creating an HTTP Client

```typescript
import { createKyClient } from '@outputai/http';
import { FatalError, ValidationError } from '@outputai/core';

const RETRY_STATUS_CODES = [ 408, 429, 500, 502, 503, 504 ];
const FATAL_STATUS_CODES = [ 401, 403, 404 ];

const client = createKyClient( {
  timeout: 30000,
  retry: {
    limit: 3,
    statusCodes: RETRY_STATUS_CODES
  },
  hooks: {
    beforeError: [
      ( { error } ) => {
        const status = error.response?.status;
        const message = error.message;

        if ( status && FATAL_STATUS_CODES.includes( status ) ) {
          throw new FatalError(
            `HTTP ${status} error: ${message}. This is a permanent error.`
          );
        }

        throw new ValidationError(
          `HTTP request failed: ${message}`
        );
      }
    ]
  }
} );
```

### Making HTTP Requests

```typescript
// GET request
const response = await client.get( 'https://api.example.com/data' );
const data = await response.json();

// POST request with JSON body
const response = await client.post( 'https://api.example.com/submit', {
  json: { field: 'value' }
} );

// HEAD request (check URL accessibility)
const response = await client.head( url );
const contentType = response.headers.get( 'content-type' );
```

When a non-`HEAD` request only uses response metadata, such as `response.url`, `response.status`, or headers, cancel the
unused body in a `finally` block. Responses read with `.json()`, `.text()`, etc. are already consumed.

```typescript
const response = await client.get( url );

try {
  return response.url;
} finally {
  await response.body?.cancel();
}
```

**Related Skill**: `output-dev-http-client-create` for creating shared clients

## LLM Operations

### Important: Define LLM Schemas in types.ts

Schemas used in `aiSdk.Output.object()` **must** be defined in `types.ts` and imported -- never defined inline in step functions. Inline schemas lead to duplication, drift between the step's `outputSchema` and the LLM schema, and make it harder to maintain types.

```typescript
// WRONG - inline schema in aiSdk.Output.object()
output: aiSdk.Output.object( {
  schema: z.object( {
    analysis: z.string()
  } )
} )

// CORRECT - import from types.ts
import { AnalysisLlmSchema } from './types.js';
// ...
output: aiSdk.Output.object( {
  schema: AnalysisLlmSchema
} )
```

### Using generateText with aiSdk.Output.object()

The `variables` field accepts scalars, nested objects, and arrays. Use Liquid loops and dot notation in the prompt when it owns presentation; pre-format in the step when the exact rendered text is application logic.

`generateText` arguments: `prompt`, `promptDir`, `variables`, `tools`, `output`, `toolChoice`, `stopWhen`, `abortSignal`.

```typescript
import { generateText, aiSdk } from '@outputai/llm';
import {
  AnalyzeContentInputSchema,
  AnalyzeContentOutputSchema,
  AnalysisLlmSchema
} from './types.js';

export const analyzeContent = step( {
  name: 'analyzeContent',
  description: 'Analyze content using Claude',
  inputSchema: AnalyzeContentInputSchema,
  outputSchema: AnalyzeContentOutputSchema,
  fn: async ( { content } ) => {
    const { output } = await generateText( {
      prompt: 'analyzeContent@v1',
      variables: {
        content
      },
      output: aiSdk.Output.object( {
        schema: AnalysisLlmSchema
      } )
    } );

    return { analysis: output.analysis };
  }
} );
```

### Using generateText

```typescript
import { generateText } from '@outputai/llm';
import { SummarizeInputSchema, SummarizeOutputSchema } from './types.js';

export const generateSummary = step( {
  name: 'generateSummary',
  description: 'Generate a text summary',
  inputSchema: SummarizeInputSchema,
  outputSchema: SummarizeOutputSchema,
  fn: async ( { content } ) => {
    const { result } = await generateText( {
      prompt: 'summarize@v1',
      variables: { content }
    } );

    return { summary: result };
  }
} );
```

**Related Skill**: `output-dev-prompt-file` for creating prompt files

### Streaming LLM Progress

Prefer `generateTextWithStreaming()` in steps when the caller needs progress callbacks and a complete result:

```typescript
import { generateTextWithStreaming } from '@outputai/llm';

const reportProgress = ( chunk: string ) => process.stdout.write( chunk );

const result = await generateTextWithStreaming( {
  prompt: 'summarize@v1',
  variables: { content },
  onChunk( { chunk } ) {
    if ( chunk.type === 'text-delta' ) {
      reportProgress( chunk.text );
    }
  }
} );

return result.result;
```

The returned promise rejects on stream failures, allowing Temporal to retry the activity. Use `streamText()` only when direct stream access is required; capture `onError` and throw the captured error after consumption. See `output-dev-llm-streaming` for complete patterns.

## Error Handling

### FatalError (Non-Retryable)

Use FatalError for permanent failures that should not be retried:

```typescript
import { FatalError } from '@outputai/core';
import { credentials } from '@outputai/core/credentials';

// Authentication failures
if ( response.status === 401 ) {
  throw new FatalError( 'Invalid API key' );
}

// Invalid input that cannot be fixed by retry
if ( !input.requiredField ) {
  throw new FatalError( 'Missing required field: requiredField' );
}

// Resource not found
if ( response.status === 404 ) {
  throw new FatalError( `Resource not found: ${resourceId}` );
}

// Configuration errors
if ( !credentials.get( 'service.api_key' ) ) {
  throw new FatalError( 'service.api_key credential not set' );
}
```

### ValidationError (Retryable)

Use ValidationError for temporary failures that may succeed on retry:

```typescript
import { ValidationError } from '@outputai/core';

// Rate limiting
if ( response.status === 429 ) {
  throw new ValidationError( 'Rate limit exceeded, will retry' );
}

// Temporary service unavailability
if ( response.status === 503 ) {
  throw new ValidationError( 'Service temporarily unavailable' );
}

// Network errors
try {
  const response = await client.get( url );
} catch ( error ) {
  throw new ValidationError( `Network error: ${error.message}` );
}

// Empty response that might be temporary
if ( results.length === 0 ) {
  throw new ValidationError( 'No results returned, will retry' );
}
```

**Related Skill**: `output-error-try-catch` for proper error handling patterns

## Complete Example

Based on a real workflow step:

```typescript
import { step, z, FatalError, ValidationError } from '@outputai/core';
import { createKyClient } from '@outputai/http';
import { generateText, aiSdk } from '@outputai/llm';

import { GeminiImageService } from '../../shared/clients/gemini_client.js';
import {
  GenerateImageIdeasInputSchema,
  GenerateImagesInputSchema,
  ImageIdeasSchema
} from './types.js';

const RETRY_STATUS_CODES = [ 408, 429, 500, 502, 503, 504 ];
const FATAL_STATUS_CODES = [ 401, 403, 404 ];

const client = createKyClient( {
  timeout: 30000,
  retry: {
    limit: 3,
    statusCodes: RETRY_STATUS_CODES
  },
  hooks: {
    beforeError: [
      ( { error } ) => {
        const status = error.response?.status;
        const message = error.message;

        if ( status && FATAL_STATUS_CODES.includes( status ) ) {
          throw new FatalError( `HTTP ${status} error: ${message}` );
        }

        throw new ValidationError( `HTTP request failed: ${message}` );
      }
    ]
  }
} );

// Step 1: Generate Ideas using LLM
export const generateImageIdeas = step( {
  name: 'generateImageIdeas',
  description: 'Generate creative infographic prompt ideas using Claude',
  inputSchema: GenerateImageIdeasInputSchema,
  outputSchema: z.array( z.string() ),
  fn: async ( { content, numberOfIdeas, colorPalette, artDirection } ) => {
    const { output } = await generateText( {
      prompt: 'generateImageIdeas@v1',
      variables: {
        content,
        numberOfIdeas,
        colorPalette: colorPalette || '',
        artDirection: artDirection || ''
      },
      output: aiSdk.Output.object( {
        schema: ImageIdeasSchema
      } )
    } );

    return output.ideas;
  }
} );

// Step 2: Generate Images using external API
export const generateImages = step( {
  name: 'generateImages',
  description: 'Generate images using Gemini API',
  inputSchema: GenerateImagesInputSchema,
  outputSchema: z.array( z.string() ),
  fn: async ( { input, prompt } ) => {
    const geminiImageService = new GeminiImageService();

    const generatedImages = await geminiImageService.generateImage( {
      prompt,
      aspectRatio: input.aspectRatio,
      resolution: input.resolution,
      numberOfImages: input.numberOfGenerations
    } );

    if ( generatedImages.length === 0 ) {
      throw new ValidationError( 'No images were generated by Gemini' );
    }

    return generatedImages;
  }
} );

// Step 3: Validate URLs using HTTP client
export const validateReferenceImages = step( {
  name: 'validateReferenceImages',
  description: 'Validates that all provided reference image URLs are accessible',
  inputSchema: z.object( {
    referenceImageUrls: z.array( z.string() ).optional()
  } ),
  outputSchema: z.boolean(),
  fn: async ( { referenceImageUrls } ) => {
    if ( !referenceImageUrls || referenceImageUrls.length === 0 ) {
      return true;
    }

    for ( const [ index, url ] of referenceImageUrls.entries() ) {
      const response = await client.head( url );
      const contentType = response.headers.get( 'content-type' );

      if ( contentType && !contentType.startsWith( 'image/' ) ) {
        throw new FatalError(
          `Reference URL ${index + 1} (${url}) is not an image file`
        );
      }
    }

    return true;
  }
} );
```

## Best Practices

### 1. One Responsibility Per Step

```typescript
// Good - focused step
export const fetchUserData = step( {
  name: 'fetchUserData',
  description: 'Fetch user data from the API'
  // ...
} );

// Avoid - step doing too much
export const fetchAndProcessAndSaveUserData = step( {
  name: 'fetchAndProcessAndSaveUserData'
  // ...
} );
```

### 2. Clear Error Messages

```typescript
// Good - specific error message
throw new FatalError( `Invalid API key for service: ${serviceName}` );

// Avoid - generic error message
throw new FatalError( 'Error occurred' );
```

### 3. Validate Input Early

```typescript
fn: async input => {
  if ( !input.url.startsWith( 'https://' ) ) {
    throw new FatalError( 'URL must use HTTPS protocol' );
  }

  const response = await client.get( input.url );
  // ...
}
```

## Verification Checklist

- [ ] `step`, `z`, `FatalError`, `ValidationError` imported from `@outputai/core`
- [ ] `createKyClient` imported from `@outputai/http` (not axios)
- [ ] `generateText` and `aiSdk` imported from `@outputai/llm` (not direct provider)
- [ ] Structured output uses `aiSdk.Output.object()` with `.describe()` (not `.min()/.max()/.length()`) on number and array schemas
- [ ] Schemas for `aiSdk.Output.object()` are defined in `types.ts` and imported, not inline
- [ ] All imports use `.js` extension
- [ ] Named exports used for each step
- [ ] Each step has `name`, `description`, `inputSchema`, `outputSchema`, `fn`
- [ ] FatalError used for non-retryable failures
- [ ] ValidationError used for retryable failures
- [ ] Non-HEAD HTTP responses are consumed or cancelled when only metadata is used
- [ ] No bare try-catch blocks that swallow errors
- [ ] Steps only import allowed dependencies (local files, shared code)
- [ ] No imports of other steps, evaluators, or workflows
- [ ] Code follows style conventions (see `output-dev-code-style`)

## Related Skills

- `output-dev-workflow-function` - Orchestrating steps in workflow.ts
- `output-dev-evaluator-function` - Using steps in evaluator functions
- `output-dev-types-file` - Defining step input/output schemas
- `output-dev-code-style` - Code formatting and style conventions
- `output-dev-http-client-create` - Creating shared HTTP clients
- `output-dev-llm-streaming` - Streaming LLM progress with Temporal-safe failures
- `output-dev-prompt-file` - Creating prompt files for LLM operations
- `output-error-try-catch` - Proper error handling patterns
- `output-error-direct-io` - Avoiding direct I/O in workflows

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.