Claude Code Plugins

Community-maintained marketplace

Feedback

output-dev-http-client-create

@growthxai/output-claude-plugins
0
0

Create shared HTTP clients in src/clients/ for Output SDK workflows. Use when integrating external APIs, creating service wrappers, or standardizing HTTP operations.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name output-dev-http-client-create
description Create shared HTTP clients in src/clients/ for Output SDK workflows. Use when integrating external APIs, creating service wrappers, or standardizing HTTP operations.
allowed-tools Read, Write, Edit, Glob

Creating HTTP Clients

Overview

This skill documents how to create shared HTTP clients for Output SDK workflows. Clients are stored in src/clients/ and shared across all workflows to ensure consistent error handling, retry logic, and API integration patterns.

When to Use This Skill

  • Integrating a new external API service
  • Creating a reusable HTTP wrapper for a service
  • Standardizing error handling for API calls
  • Moving inline HTTP logic to a shared client

Location Convention

HTTP clients are stored in the central clients folder:

src/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 client

Important: Clients are shared across ALL workflows. Do NOT create per-workflow HTTP clients.

Import Pattern in Workflows

Use the #clients path alias to import clients:

// CORRECT - Use path alias
import { GeminiImageService } from '#clients/gemini_client.js';
import { parseResumeWithJina } from '#clients/jina_client.js';

// WRONG - Relative path from workflow
import { GeminiImageService } from '../../../clients/gemini_client.js';

Critical Import Rules

HTTP Client Import

// CORRECT - Use @output.ai/http wrapper
import { httpClient } from '@output.ai/http';

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

Error Types Import

// CORRECT - Import error types from @output.ai/core
import { FatalError, ValidationError } from '@output.ai/core';

// WRONG - Custom error classes
class MyCustomError extends Error { ... }

Basic Client Structure

Simple Function-Based Client

import { FatalError, ValidationError } from '@output.ai/core';
import { httpClient } from '@output.ai/http';

const API_KEY = process.env.SERVICE_API_KEY || '';
const BASE_URL = 'https://api.service.com';

const serviceClient = httpClient({
  prefixUrl: 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 serviceClient.get('endpoint', {
    searchParams: { q: query }
  });

  const data = await response.json();

  if (!data.results) {
    throw new FatalError('No results returned from service');
  }

  return data;
}

Class-Based Client

import { FatalError, ValidationError } from '@output.ai/core';
import { httpClient } from '@output.ai/http';

export interface ServiceOptions {
  model?: string;
  timeout?: number;
}

export class ServiceClient {
  private readonly client: ReturnType<typeof httpClient>;
  private readonly model: string;

  constructor(apiKey = process.env.SERVICE_API_KEY) {
    if (!apiKey) {
      throw new FatalError(
        'ServiceClient: No API Key provided (SERVICE_API_KEY)'
      );
    }

    this.client = httpClient({
      prefixUrl: 'https://api.service.com',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        '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}`);
    }
  }
}

Real-World Examples

Example 1: Jina Client (Function-Based)

import { FatalError } from '@output.ai/core';
import { httpClient } from '@output.ai/http';

const JINA_API_KEY = process.env.JINA_API_KEY || '';
const JINA_BASE_URL = 'https://r.jina.ai';

const jinaClient = httpClient({
  prefixUrl: 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 jinaClient.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 jinaClient.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;
}

Example 2: Gemini Client (Class-Based)

import { GoogleGenerativeAI } from '@google/generative-ai';
import { FatalError, ValidationError } from '@output.ai/core';

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;
  private readonly model: string = 'gemini-3-pro-image-preview';

  constructor(apiKey = process.env.GOOGLE_GEMINI_API_KEY || process.env.GOOGLE_CLOUD_API_KEY) {
    if (!apiKey) {
      throw new FatalError(
        'GeminiImageService: No API Key provided (GOOGLE_GEMINI_API_KEY or GOOGLE_CLOUD_API_KEY).'
      );
    }
    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}`);
    }
  }
}

Error Handling Patterns

HTTP Status Code Handling

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

const client = httpClient({
  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}`);
      }
    ]
  }
});

Error Type Guidelines

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

Best Practices

1. Validate API Keys Early

constructor(apiKey = process.env.API_KEY) {
  if (!apiKey) {
    throw new FatalError('API_KEY environment variable not set');
  }
  // ...
}

2. Document Functions with JSDoc

/**
 * 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> {
  // ...
}

3. Use Consistent Timeouts

// Standard timeout: 30 seconds
timeout: 30000

// Long-running operations: 60 seconds
timeout: 60000

4. Export TypeScript Interfaces

// Export interfaces for consumers
export interface ServiceResponse {
  data: {
    id: string;
    content: string;
  };
  metadata: {
    processedAt: string;
  };
}

Verification Checklist

  • Client file located in src/clients/ directory
  • File named {service}_client.ts
  • httpClient imported from @output.ai/http (not axios)
  • FatalError and ValidationError imported from @output.ai/core
  • API key validation in constructor/initialization
  • Retry configuration for appropriate status codes
  • FatalError used for 401, 403, 404 responses
  • ValidationError used for 429, 5xx responses
  • JSDoc documentation for exported functions
  • TypeScript interfaces exported for response types

Related Skills

  • output-dev-step-function - Using clients in step functions
  • output-dev-folder-structure - Understanding project layout
  • output-error-http-client - Troubleshooting HTTP issues
  • output-error-try-catch - Proper error handling patterns