Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Create shared HTTP clients in src/shared/clients/ for Output SDK workflows. Use when integrating external APIs, creating service wrappers, or standardizing HTTP operations.
.claude/skills/growthxai-output-dev-http-client-create/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 26% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 193% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 135% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 54% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 41% | 0% |
This skill documents how to create shared HTTP clients for Output SDK workflows. Clients are stored in src/shared/clients/ and shared across all workflows to ensure consistent error handling, retry logic, and API integration patterns.
HTTP clients are stored in the shared clients folder:
src/shared/clients/
├── gemini_client.ts # Google Gemini API client
├── jina_client.ts # Jina AI client
├── perplexity_client.ts # Perplexity API client
└── {service}_client.ts # Your new clientImportant: Clients are shared across ALL workflows. Do NOT create per-workflow HTTP clients.
src/shared/
├── clients/ # API clients (this skill)
├── utils/ # Utility functions & helpers
├── services/ # Business logic services
├── steps/ # Shared step definitions (optional)
└── evaluators/ # Shared evaluators (optional)Use relative imports from workflow files to shared clients:
typescript// CORRECT - Relative path from workflow steps.ts import { GeminiImageService } from '../../shared/clients/gemini_client.js'; import { parseResumeWithJina } from '../../shared/clients/jina_client.js'; // From shared steps (if used) import { JinaClient } from '../clients/jina_client.js';
typescript// CORRECT - Use @outputai/http wrapper import { createKyClient } from '@outputai/http'; // WRONG - Never use axios directly import axios from 'axios';
typescript// CORRECT - Import error types from @outputai/core import { FatalError, ValidationError } from '@outputai/core'; // WRONG - Custom error classes class MyCustomError extends Error { ... }
typescript// CORRECT - Use @outputai/credentials for secrets import { credentials } from '@outputai/credentials'; const apiKey = credentials.require('service.api_key'); // WRONG - Never use process.env for secrets const apiKey = process.env.SERVICE_API_KEY;
typescriptimport { FatalError, ValidationError } from '@outputai/core'; import { createKyClient } from '@outputai/http'; import { credentials } from '@outputai/credentials'; const API_KEY = credentials.require('service.api_key'); const BASE_URL = 'https://api.service.com'; const client = createKyClient({ prefix: BASE_URL, headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' }, timeout: 30000, retry: { limit: 3, statusCodes: [408, 429, 500, 502, 503, 504] } }); /** * Fetch data from the service * * @param query - Search query string * @returns Processed response data * @throws {FatalError} If authentication fails or resource not found * @throws {ValidationError} If temporary error occurs */ export async function fetchServiceData(query: string): Promise<ServiceResponse> { const response = await client.get('endpoint', { searchParams: { q: query } }); const data = await response.json(); if (!data.results) { throw new FatalError('No results returned from service'); } return data; }
typescriptimport { FatalError, ValidationError } from '@outputai/core'; import { createKyClient } from '@outputai/http'; import { credentials } from '@outputai/credentials'; export interface ServiceOptions { model?: string; timeout?: number; } export class ServiceClient { private readonly client: ReturnType<typeof createKyClient>; private readonly model: string; constructor(apiKey?: string) { const key = apiKey ?? credentials.require('service.api_key'); this.client = createKyClient({ prefix: 'https://api.service.com', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, timeout: 30000, retry: { limit: 3, statusCodes: [408, 429, 500, 502, 503, 504] } }); this.model = 'default-model'; } async process(input: ProcessInput): Promise<ProcessOutput> { try { const response = await this.client.post('process', { json: { model: this.model, input } }); return await response.json(); } catch (error: unknown) { const err = error as { status?: number; message?: string }; if (err.status === 429) { throw new ValidationError(`Rate limit exceeded: ${err.message}`); } if (err.status === 401 || err.status === 403) { throw new FatalError(`Authentication failed: ${err.message}`); } throw new ValidationError(`Service call failed: ${err.message}`); } } }
typescriptimport { FatalError } from '@outputai/core'; import { createKyClient } from '@outputai/http'; import { credentials } from '@outputai/credentials'; const JINA_API_KEY = credentials.require('jina.api_key'); const JINA_BASE_URL = 'https://r.jina.ai'; const client = createKyClient({ prefix: JINA_BASE_URL, headers: { Authorization: `Bearer ${JINA_API_KEY}`, Accept: 'application/json' }, timeout: 30000, retry: { limit: 3, statusCodes: [408, 413, 429, 500, 502, 503, 504] } }); /** * Parse PDF resume using Jina Reader API */ export async function parseResumeWithJina(base64Pdf: string): Promise<string> { const response = await client.post('', { json: { pdf: base64Pdf }, headers: { 'Content-Type': 'application/json' } }); const data: { data: { content: string; title?: string; }; } = await response.json(); if (!data.data?.content) { throw new FatalError('No content returned from Jina PDF parser'); } return data.data.content; } /** * Scrape text content from URL using Jina Reader */ export async function scrapeTextWithJina(url: string): Promise<string> { const response = await client.get(url, { headers: { 'X-Return-Format': 'text', 'X-No-Cache': 'true', 'X-Timeout': '30' } }); const data: { data: { text?: string; content?: string; }; } = await response.json(); const textContent = data.data?.text || data.data?.content; if (!textContent) { throw new FatalError(`No text content returned from URL: ${url}`); } return textContent; }
typescriptimport { GoogleGenerativeAI } from '@google/generative-ai'; import { FatalError, ValidationError } from '@outputai/core'; import { credentials } from '@outputai/credentials'; export interface GeminiImageGenerationOptions { prompt: string; referenceImages?: Array<{ inlineData: { mimeType: string; data: string; }; }>; aspectRatio?: string; resolution?: string; numberOfImages?: number; } export class GeminiImageService { private readonly client: GoogleGenerativeAI; // current as of 2026-05-04 — run output-dev-model-selection for the latest private readonly model: string = 'gemini-3-pro-image'; constructor(apiKey = credentials.require('google.api_key')) { if (!apiKey) { throw new FatalError( 'GeminiImageService: No API Key provided (google.api_key credential).' ); } this.client = new GoogleGenerativeAI(apiKey); } async generateImage(options: GeminiImageGenerationOptions): Promise<string[]> { const { prompt, referenceImages = [], aspectRatio = '1:1', resolution = '1K', numberOfImages = 1 } = options; try { const model = this.client.getGenerativeModel({ model: this.model }); const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = []; if (referenceImages.length > 0) { referenceImages.forEach(img => parts.push(img)); } const finalPrompt = `${prompt}\n\nGenerate this as a ${aspectRatio} aspect ratio image at ${resolution} resolution.`; parts.push({ text: finalPrompt }); const result = await model.generateContent({ contents: [{ role: 'user', parts }], generationConfig: { temperature: 1.0, topP: 0.95, candidateCount: numberOfImages, maxOutputTokens: 8192 } }); const images: string[] = []; const candidates = result.response.candidates || []; for (const candidate of candidates) { if (candidate.content?.parts) { for (const part of candidate.content.parts) { if (part.inlineData?.data) { images.push(part.inlineData.data); } } } } if (images.length === 0) { throw new ValidationError('No images were generated by Gemini'); } return images; } catch (error: unknown) { const err = error as { status?: number; message?: string }; if (err.status === 429) { throw new ValidationError(`Gemini rate limit exceeded: ${err.message}`); } if (err.status === 401 || err.status === 403) { throw new FatalError(`Gemini authentication failed: ${err.message}`); } throw new ValidationError(`Gemini image generation failed: ${err.message}`); } } }
typescriptconst RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504]; const FATAL_STATUS_CODES = [401, 403, 404]; const client = createKyClient({ 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}`); } ] } });
| Status Code | Error Type | Reason | |-------------|------------|--------| | 401, 403 | FatalError | Auth failures won't succeed on retry | | 404 | FatalError | Resource doesn't exist | | 408 | ValidationError | Timeout, may succeed on retry | | 429 | ValidationError | Rate limit, will succeed after wait | | 500+ | ValidationError | Server errors may be temporary |
Prefer @outputai/credentials over process.env for secrets management. See output-dev-credentials skill for details.
typescriptimport { credentials } from '@outputai/credentials'; // credentials.require() throws MissingCredentialError if not found const apiKey = credentials.require('service.api_key'); // credentials.get() returns undefined or default if not found const region = credentials.get('aws.region', 'us-east-1');
typescript/** * Fetch user profile from external service * * @param userId - Unique user identifier * @returns User profile data * @throws {FatalError} If user not found or auth fails * @throws {ValidationError} If temporary error occurs * * @example * const profile = await fetchUserProfile('user-123'); */ export async function fetchUserProfile(userId: string): Promise<UserProfile> { // ... }
typescript// Standard timeout: 30 seconds timeout: 30000 // Long-running operations: 60 seconds timeout: 60000
createKyClient follows Fetch response-body semantics: callers own returned response bodies. Prefer body readers like .json() or .text() when the payload is needed. If a request only reads metadata such as response.url, response.status, or headers, cancel the unused body in a finally block.
typescriptconst response = await client.get( url ); try { return response.url; } finally { await response.body?.cancel(); }
HEAD requests do not have response bodies, so this is only needed for methods that can return one.
typescript// Export interfaces for consumers export interface ServiceResponse { data: { id: string; content: string; }; metadata: { processedAt: string; }; }
src/shared/clients/ directory{service}_client.tscreateKyClient imported from @outputai/http (not axios)FatalError and ValidationError imported from @outputai/coreoutput-dev-step-function - Using clients in step functionsoutput-dev-evaluator-function - Using clients in evaluatorsoutput-dev-folder-structure - Understanding project layoutoutput-dev-credentials - Encrypted secrets managementoutput-error-http-client - Troubleshooting HTTP issuesoutput-error-try-catch - Proper error handling patterns| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | fail→pass | 23,470 | 10,792 | -54% | 1 | 1 | 0% | 4,842 | 6,117 | +26% | 0 | 0 | — |
case-08 | fail→pass | 12,701 | 9,485 | -25% | 1 | 1 | 0% | 1,963 | 5,749 | +193% | 0 | 0 | — |
case-09 | fail→pass | 13,919 | 11,460 | -18% | 1 | 1 | 0% | 2,625 | 6,162 | +135% | 0 | 0 | — |
case-01 | fail→pass | 24,877 | 18,255 | -27% | 1 | 1 | 0% | 5,030 | 7,728 | +54% | 0 | 0 | — |
case-02 | fail→pass | 20,984 | 8,360 | -60% | 1 | 1 | 0% | 3,868 | 5,437 | +41% | 0 | 0 | — |
case-04 | pass→pass | 12,274 | 14,676 | +20% | 1 | 1 | 0% | 2,253 | 6,503 | +189% | 0 | 0 | — |
case-05 | pass→pass | 15,149 | 14,936 | -1% | 1 | 1 | 0% | 3,129 | 6,729 | +115% | 0 | 0 | — |
case-06 | pass→fail | 7,223 | 5,255 | -27% | 1 | 1 | 0% | 1,247 | 4,675 | +275% | 0 | 0 | — |
case-07 | fail→pass | 7,923 | 3,409 | -57% | 1 | 1 | 0% | 1,351 | 4,306 | +219% | 0 | 0 | — |
case-10 | fail→pass | 13,545 | 8,214 | -39% | 1 | 1 | 0% | 2,445 | 5,408 | +121% | 0 | 0 | — |
case-11 | fail→pass | 12,040 | 4,128 | -66% | 1 | 1 | 0% | 2,050 | 4,502 | +120% | 0 | 0 | — |
case-12 | fail→pass | 14,007 | 6,365 | -55% | 1 | 1 | 0% | 2,362 | 5,041 | +113% | 0 | 0 | — |
case-13 | fail→pass | 16,126 | 11,240 | -30% | 1 | 1 | 0% | 3,246 | 5,959 | +84% | 0 | 0 | — |
case-14 | fail→pass | 7,360 | 2,766 | -62% | 1 | 1 | 0% | 1,112 | 4,237 | +281% | 0 | 0 | — |
case-15 | fail→pass | 7,599 | 7,553 | -1% | 1 | 1 | 0% | 1,387 | 5,179 | +273% | 0 | 0 | — |
case-16 | fail→pass | 19,210 | 13,415 | -30% | 1 | 1 | 0% | 3,710 | 6,516 | +76% | 0 | 0 | — |
case-17 | fail→pass | 14,864 | 9,100 | -39% | 1 | 1 | 0% | 2,829 | 5,642 | +99% | 0 | 0 | — |
case-18 | pass→pass | 14,413 | 4,324 | -70% | 1 | 1 | 0% | 2,420 | 4,504 | +86% | 0 | 0 | — |
case-19 | fail→pass | 12,912 | 9,904 | -23% | 1 | 1 | 0% | 2,449 | 5,906 | +141% | 0 | 0 | — |
case-20 | fail→pass | 17,513 | 11,216 | -36% | 1 | 1 | 0% | 4,197 | 6,000 | +43% | 0 | 0 | — |
case-21 | fail→pass | 12,861 | 4,190 | -67% | 1 | 1 | 0% | 1,916 | 4,451 | +132% | 0 | 0 | — |
case-22 | fail→pass | 8,807 | 3,454 | -61% | 1 | 1 | 0% | 1,631 | 4,506 | +176% | 0 | 0 | — |
case-23 | pass→pass | 8,849 | 6,374 | -28% | 1 | 1 | 0% | 1,615 | 5,026 | +211% | 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. 23 cases were attempted. The headline lift of +74 percentage points is the difference between those two pass rates over the 23 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
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.