Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Create step functions in steps.ts for Output SDK workflows. Use when implementing I/O operations, error handling, HTTP requests, or LLM calls.
.claude/skills/growthxai-output-dev-step-function/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 191% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 236% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 141% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 196% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 237% | 0% |
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.
For smaller workflows, use a single steps.ts file:
src/workflows/{workflow-name}/
├── workflow.ts
├── steps.ts # All steps in one file
├── types.ts
└── ...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
└── ...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)Steps are Temporal activities with strict import rules to ensure deterministic replay.
./utils.js, ./types.js, ./helpers.js./clients/pokeapi.js, ./lib/helpers.js../../shared/utils/*.js../../shared/clients/*.js../../shared/services/*.jsExample of WRONG imports:
typescript// WRONG - steps cannot import other steps import { otherStep } from '../../shared/steps/other.js'; // ✗ import { anotherStep } from './other_steps.js'; // ✗
typescript// CORRECT - Import from @outputai/core import { step, z, FatalError, ValidationError } from '@outputai/core'; // WRONG - Never import z from zod import { z } from 'zod';
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
typescript// CORRECT - Use @outputai/llm wrapper import { generateText, Output } from '@outputai/llm'; // WRONG - Never call LLM providers directly import OpenAI from 'openai';
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';
typescriptimport { step, z, FatalError, ValidationError } from '@outputai/core'; import { createKyClient } from '@outputai/http'; import { generateText, Output } 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 */ }; } } );
Unique identifier for the step. Use camelCase.
typescriptname: 'generateImageIdeas'
Human-readable description of the step's purpose.
typescriptdescription: 'Generate creative infographic prompt ideas using Claude'
Schema for validating step input. Define in types.ts and import.
typescriptinputSchema: z.object( { content: z.string(), numberOfIdeas: z.number() } )
Schema for validating step output. Define in types.ts and import.
typescriptoutputSchema: z.array( z.string() )
The step execution function. This is where I/O operations happen.
typescriptfn: async input => { const result = await someExternalService( input ); return result; }
typescriptimport { 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}` ); } ] } } );
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.
typescriptconst 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
Schemas used in 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 Output.object() output: Output.object( { schema: z.object( { analysis: z.string() } ) } ) // CORRECT - import from types.ts import { AnalysisLlmSchema } from './types.js'; // ... output: Output.object( { schema: AnalysisLlmSchema } )
Important: The variables field only accepts string | number | boolean values. Arrays and objects must be pre-formatted into strings in the step before passing. See output-dev-prompt-file for the full constraint and examples.
typescriptimport { generateText, Output } 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: Output.object( { schema: AnalysisLlmSchema } ) } ); return { analysis: output.analysis }; } } );
typescriptimport { 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
Use FatalError for permanent failures that should not be retried:
typescriptimport { FatalError } from '@outputai/core'; import { credentials } from '@outputai/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' ); }
Use ValidationError for temporary failures that may succeed on retry:
typescriptimport { 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
Based on a real workflow step:
typescriptimport { step, z, FatalError, ValidationError } from '@outputai/core'; import { createKyClient } from '@outputai/http'; import { generateText, Output } 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: 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; } } );
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' // ... } );
typescript// Good - specific error message throw new FatalError( `Invalid API key for service: ${serviceName}` ); // Avoid - generic error message throw new FatalError( 'Error occurred' );
typescriptfn: async input => { if ( !input.url.startsWith( 'https://' ) ) { throw new FatalError( 'URL must use HTTPS protocol' ); } const response = await client.get( input.url ); // ... }
step, z, FatalError, ValidationError imported from @outputai/corecreateKyClient imported from @outputai/http (not axios)generateText and Output imported from @outputai/llm (not direct provider)Output.object() with .describe() (not .min()/.max()/.length()) on number and array schemasOutput.object() are defined in types.ts and imported, not inline.js extensionname, description, inputSchema, outputSchema, fnoutput-dev-code-style)output-dev-workflow-function - Orchestrating steps in workflow.tsoutput-dev-evaluator-function - Using steps in evaluator functionsoutput-dev-types-file - Defining step input/output schemasoutput-dev-code-style - Code formatting and style conventionsoutput-dev-http-client-create - Creating shared HTTP clientsoutput-dev-prompt-file - Creating prompt files for LLM operationsoutput-error-try-catch - Proper error handling patternsoutput-error-direct-io - Avoiding direct I/O in workflows| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 10,900 | 8,829 | -19% | 1 | 1 | 0% | 2,118 | 6,164 | +191% | 0 | 0 | — |
case-02 | fail→pass | 10,325 | 7,409 | -28% | 1 | 1 | 0% | 1,700 | 5,708 | +236% | 0 | 0 | — |
case-03 | fail→pass | 15,619 | 11,732 | -25% | 1 | 1 | 0% | 2,782 | 6,714 | +141% | 0 | 0 | — |
case-04 | pass→pass | 13,274 | 8,961 | -32% | 1 | 1 | 0% | 2,322 | 6,026 | +160% | 0 | 0 | — |
case-05 | fail→pass | 11,322 | 6,130 | -46% | 1 | 1 | 0% | 1,886 | 5,577 | +196% | 0 | 0 | — |
case-06 | pass→pass | 12,781 | 5,003 | -61% | 1 | 1 | 0% | 2,003 | 5,215 | +160% | 0 | 0 | — |
case-07 | pass→pass | 7,061 | 3,533 | -50% | 1 | 1 | 0% | 1,119 | 4,892 | +337% | 0 | 0 | — |
case-08 | fail→pass | 7,969 | 2,935 | -63% | 1 | 1 | 0% | 1,423 | 4,799 | +237% | 0 | 0 | — |
case-09 | fail→pass | 15,611 | 4,680 | -70% | 1 | 1 | 0% | 2,620 | 5,211 | +99% | 0 | 0 | — |
case-10 | fail→pass | 7,389 | 7,839 | +6% | 1 | 1 | 0% | 1,324 | 5,828 | +340% | 0 | 0 | — |
case-11 | fail→pass | 17,440 | 8,986 | -48% | 1 | 1 | 0% | 2,569 | 5,902 | +130% | 0 | 0 | — |
case-12 | fail→pass | 11,488 | 3,582 | -69% | 1 | 1 | 0% | 1,847 | 4,914 | +166% | 0 | 0 | — |
case-13 | fail→pass | 10,202 | 4,157 | -59% | 1 | 1 | 0% | 1,705 | 4,989 | +193% | 0 | 0 | — |
case-14 | fail→pass | 10,821 | 4,399 | -59% | 1 | 1 | 0% | 1,798 | 5,115 | +184% | 0 | 0 | — |
case-15 | fail→pass | 14,004 | 6,704 | -52% | 1 | 1 | 0% | 2,761 | 5,700 | +106% | 0 | 0 | — |
case-16 | pass→pass | 16,287 | 15,006 | -8% | 1 | 1 | 0% | 2,462 | 7,151 | +190% | 0 | 0 | — |
case-17 | fail→pass | 12,721 | 8,555 | -33% | 1 | 1 | 0% | 2,097 | 5,964 | +184% | 0 | 0 | — |
case-18 | fail→pass | 16,962 | 3,157 | -81% | 1 | 1 | 0% | 2,603 | 4,833 | +86% | 0 | 0 | — |
case-19 | fail→pass | 12,739 | 11,387 | -11% | 1 | 1 | 0% | 2,107 | 6,403 | +204% | 0 | 0 | — |
case-20 | pass→pass | 9,368 | 5,087 | -46% | 1 | 1 | 0% | 1,413 | 5,207 | +269% | 0 | 0 | — |
case-21 | fail→pass | 11,975 | 2,596 | -78% | 1 | 1 | 0% | 1,919 | 4,726 | +146% | 0 | 0 | — |
case-22 | fail→pass | 9,522 | 5,158 | -46% | 1 | 1 | 0% | 1,512 | 5,326 | +252% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +77 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.