Output Dev Step Function

by growthxaia4f6bd40ab0eNo licenseListed Oct 8, 2026Updated Oct 8, 2026

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

AI-generated overview

Documents how to write step functions in steps.ts for Output SDK workflows, covering I/O, HTTP, LLM calls and errors.

What it does
This skill provides guidance for authoring step functions in steps.ts files for Output SDK workflows. It covers file organization, allowed imports, required step properties, HTTP client usage, LLM calls with structured output, and error handling with FatalError and ValidationError. It also includes a verification checklist and a complete multi-step example.
When to use it
Use it when implementing I/O operations, HTTP integrations, LLM-powered steps, or error handling inside Output SDK workflows. It is also relevant when creating reusable step components or checking that step code follows the SDK's import and structure rules.
Requirements
Instructions only; no scripts are shipped. It assumes an Output SDK project with the @outputai/core, @outputai/http, @outputai/llm and @outputai/credentials packages, plus Temporal-based workflows.

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 stepsimport { otherStep } from '../../shared/steps/other.js'; // ✗import { anotherStep } from './other_steps.js'; // ✗

Critical Import Patterns

Core Imports

typescript
// CORRECT - Import from @outputai/coreimport { step, z, FatalError, ValidationError } from '@outputai/core';
// WRONG - Never import z from zodimport { z } from 'zod';

HTTP Client Import

typescript
// CORRECT - Use @outputai/http wrapperimport { createKyClient } from '@outputai/http';
// WRONG - Never use axios directlyimport axios from 'axios';

Related Skill: output-error-http-client

LLM Client Import

typescript
// CORRECT - Use @outputai/llm wrapperimport { generateText, aiSdk } from '@outputai/llm';
// WRONG - Never call LLM providers directlyimport OpenAI from 'openai';

ES Module Imports

All imports MUST use .js extension:

typescript
// CORRECTimport { InputSchema, OutputSchema } from './types.js';import { GeminiService } from '../../shared/clients/gemini_client.js';
// WRONG - Missing .js extensionimport { 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 requestconst response = await client.get( 'https://api.example.com/data' );const data = await response.json();
// POST request with JSON bodyconst 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.tsimport { 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/credentials';
// Authentication failuresif ( response.status === 401 ) {  throw new FatalError( 'Invalid API key' );}
// Invalid input that cannot be fixed by retryif ( !input.requiredField ) {  throw new FatalError( 'Missing required field: requiredField' );}
// Resource not foundif ( response.status === 404 ) {  throw new FatalError( `Resource not found: ${resourceId}` );}
// Configuration errorsif ( !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 limitingif ( response.status === 429 ) {  throw new ValidationError( 'Rate limit exceeded, will retry' );}
// Temporary service unavailabilityif ( response.status === 503 ) {  throw new ValidationError( 'Service temporarily unavailable' );}
// Network errorstry {  const response = await client.get( url );} catch ( error ) {  throw new ValidationError( `Network error: ${error.message}` );}
// Empty response that might be temporaryif ( 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 LLMexport 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 APIexport 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 clientexport 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 stepexport const fetchUserData = step( {  name: 'fetchUserData',  description: 'Fetch user data from the API'  // ...} );
// Avoid - step doing too muchexport const fetchAndProcessAndSaveUserData = step( {  name: 'fetchAndProcessAndSaveUserData'  // ...} );

2. Clear Error Messages

typescript
// Good - specific error messagethrow new FatalError( `Invalid API key for service: ${serviceName}` );
// Avoid - generic error messagethrow 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

Source and attribution

Source:growthxai/outputincoding_assistants/claude/plugins/outputai/skills/output-dev-step-functionat commita4f6bd4

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal