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:
import { otherStep } from '../../shared/steps/other.js' ;
import { anotherStep } from './other_steps.js' ;
Critical Import Patterns
Core Imports
import { step, z, FatalError , ValidationError } from '@outputai/core' ;
import { z } from 'zod' ;
HTTP Client Import
import { createKyClient } from '@outputai/http' ;
import axios from 'axios' ;
Related Skill : output-error-http-client
LLM Client Import
import { generateText, aiSdk } from '@outputai/llm' ;
import OpenAI from 'openai' ;
ES Module Imports
All imports MUST use .js extension:
import { InputSchema , OutputSchema } from './types.js' ;
import { GeminiService } from '../../shared/clients/gemini_client.js' ;
import { InputSchema , OutputSchema } from './types' ;
Basic Structure
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 => {
return { };
}
} );
Required Properties
name (string)
Unique identifier for the step. Use camelCase.
name : 'generateImageIdeas'
description (string)
Human-readable description of the step's purpose.
description : 'Generate creative infographic prompt ideas using Claude'
inputSchema (Zod schema)
Schema for validating step input. Define in types.ts and import.
inputSchema : z.object ( {
content : z.string (),
numberOfIdeas : z.number ()
} )
outputSchema (Zod schema)
Schema for validating step output. Define in types.ts and import.
outputSchema : z.array ( z.string () )
fn (async function)
The step execution function. This is where I/O operations happen.
fn : async input => {
const result = await someExternalService ( input );
return result;
}
HTTP Client Usage
Creating an HTTP Client
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 (
);
}
]
}
} );
Making HTTP Requests
const response = await client.get ( 'https://api.example.com/data' );
const data = await response.json ();
const response = await client.post ( 'https://api.example.com/submit' , {
json : { field : 'value' }
} );
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.
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.
output : aiSdk.Output .object ( {
schema : z.object ( {
analysis : z.string ()
} )
} )
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.
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
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:
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:
import { FatalError } from '@outputai/core' ;
import { credentials } from '@outputai/credentials' ;
if ( response.status === 401 ) {
throw new FatalError ( 'Invalid API key' );
}
if ( !input.requiredField ) {
throw new FatalError ( 'Missing required field: requiredField' );
}
if ( response.status === 404 ) {
throw new FatalError ( `Resource not found: ${resourceId} ` );
}
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:
import { ValidationError } from '@outputai/core' ;
if ( response.status === 429 ) {
throw new ValidationError ( 'Rate limit exceeded, will retry' );
}
if ( response.status === 503 ) {
throw new ValidationError ( 'Service temporarily unavailable' );
}
try {
const response = await client.get ( url );
} catch ( error ) {
throw new ValidationError ( `Network error: ${error.message} ` );
}
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:
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. ;
( status && . ( status ) ) {
( );
}
( );
}
]
}
} );
generateImageIdeas = ( {
: ,
: ,
: ,
: z. ( z. () ),
: ( { content, numberOfIdeas, colorPalette, artDirection } ) => {
{ output } = ( {
: ,
: {
content,
numberOfIdeas,
: colorPalette || ,
: artDirection ||
},
: aiSdk. . ( {
:
} )
} );
output. ;
}
} );
generateImages = ( {
: ,
: ,
: ,
: z. ( z. () ),
: ( { input, prompt } ) => {
geminiImageService = ();
generatedImages = geminiImageService. ( {
prompt,
: input. ,
: input. ,
: input.
} );
( generatedImages. === ) {
( );
}
generatedImages;
}
} );
validateReferenceImages = ( {
: ,
: ,
: z. ( {
: z. ( z. () ). ()
} ),
: z. (),
: ( { referenceImageUrls } ) => {
( !referenceImageUrls || referenceImageUrls. === ) {
;
}
( [ index, url ] referenceImageUrls. () ) {
response = client. ( url );
contentType = response. . ( );
( contentType && !contentType. ( ) ) {
(
);
}
}
;
}
} );
Best Practices
1. One Responsibility Per Step
export const fetchUserData = step ( {
name : 'fetchUserData' ,
description : 'Fetch user data from the API'
} );
export const fetchAndProcessAndSaveUserData = step ( {
name : 'fetchAndProcessAndSaveUserData'
} );
2. Clear Error Messages
throw new FatalError ( `Invalid API key for service: ${serviceName} ` );
throw new FatalError ( 'Error occurred' );
3. Validate Input Early
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