Claude Skills Guide

When building applications that interact with external APIs in Claude Code, you often find yourself repeating the same fetch boilerplate across multiple tools and scripts. A well-designed fetch API wrapper saves time, reduces errors, and makes your codebase more maintainable. This guide walks you through creating practical wrapper functions that integrate smoothly with Claude Code’s tool-calling approach.

Why Wrapper Functions Matter

Raw fetch calls require handling URLs, headers, request bodies, response parsing, and error cases repeatedly. In a Claude Code environment where you’re rapidly prototyping or building automation tools, this overhead compounds quickly. A wrapper centralizes configuration, adds sensible defaults, and provides consistent error handling across your entire project.

Consider the difference between scattered fetch calls and a unified API client:

// Without wrapper - repetitive and error-prone
const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + process.env.API_KEY
  },
  body: JSON.stringify({ name: 'test' })
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);

// With wrapper - clean and reusable
const data = await apiClient.post('/users', { name: 'test' });

The wrapper approach becomes especially valuable when combining multiple API calls in a single Claude Code session, such as fetching documentation via the supermemory skill, generating PDFs with the pdf skill, or coordinating with frontend-design workflows.

Building a Basic Wrapper

Start with a simple, flexible wrapper that handles the most common scenarios:

// api-client.js
export class ApiClient {
  constructor(baseUrl, defaultHeaders = {}) {
    this.baseUrl = baseUrl;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...defaultHeaders
    };
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseUrl}${endpoint}`;
    const config = {
      ...options,
      headers: {
        ...this.defaultHeaders,
        ...options.headers
      }
    };

    try {
      const response = await fetch(url, config);
      const contentType = response.headers.get('content-type');
      
      let data;
      if (contentType && contentType.includes('application/json')) {
        data = await response.json();
      } else {
        data = await response.text();
      }

      if (!response.ok) {
        throw new ApiError(response.status, data);
      }

      return data;
    } catch (error) {
      if (error instanceof ApiError) throw error;
      throw new ApiError(0, { message: error.message });
    }
  }

  get(endpoint, options = {}) {
    return this.request(endpoint, { ...options, method: 'GET' });
  }

  post(endpoint, body, options = {}) {
    return this.request(endpoint, {
      ...options,
      method: 'POST',
      body: JSON.stringify(body)
    });
  }

  put(endpoint, body, options = {}) {
    return this.request(endpoint, {
      ...options,
      method: 'PUT',
      body: JSON.stringify(body)
    });
  }

  delete(endpoint, options = {}) {
    return this.request(endpoint, { ...options, method: 'DELETE' });
  }
}

class ApiError extends Error {
  constructor(status, data) {
    super(data.message || 'API request failed');
    this.status = status;
    this.data = data;
  }
}

This wrapper handles JSON automatically, provides typed HTTP methods, and wraps errors consistently. The ApiError class makes it easy to handle specific status codes in your calling code.

Authentication Patterns

Most APIs require authentication. Include token management in your wrapper:

export class AuthenticatedClient extends ApiClient {
  constructor(baseUrl, tokenGetter) {
    super(baseUrl);
    this.tokenGetter = tokenGetter;
  }

  async request(endpoint, options = {}) {
    const token = await this.tokenGetter();
    const authHeaders = {
      'Authorization': `Bearer ${token}`
    };

    return super.request(endpoint, {
      ...options,
      headers: {
        ...options.headers,
        ...authHeaders
      }
    });
  }
}

// Usage with environment variable
const api = new AuthenticatedClient(
  'https://api.github.com',
  () => process.env.GITHUB_TOKEN
);

const user = await api.get('/user');

For APIs using API keys rather than tokens, modify the constructor to accept the key directly:

constructor(baseUrl, apiKey) {
  super(baseUrl, { 'X-API-Key': apiKey });
}

Retry Logic and Timeouts

Network requests fail. Build resilience directly into your wrapper:

export class ResilientClient extends ApiClient {
  constructor(baseUrl, options = {}) {
    super(baseUrl, options.headers);
    this.maxRetries = options.maxRetries ?? 3;
    this.retryDelay = options.retryDelay ?? 1000;
  }

  async request(endpoint, options = {}) {
    let lastError;
    
    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await super.request(endpoint, options);
      } catch (error) {
        lastError = error;
        
        // Don't retry on client errors (4xx)
        if (error.status >= 400 && error.status < 500) {
          throw error;
        }
        
        if (attempt < this.maxRetries) {
          await this.delay(this.retryDelay * Math.pow(2, attempt));
        }
      }
    }
    
    throw lastError;
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

This exponential backoff strategy handles transient failures gracefully—useful when calling external services during long-running Claude Code sessions that might include tdd test runs or document generation with the docx skill.

Integrating with Claude Code Tools

To use your wrapper in a Claude Code tool, export the client and relevant functions:

// tools/github-client.js
import { ResilientClient } from '../lib/api-client.js';

const github = new ResilientClient('https://api.github.com', {
  maxRetries: 3,
  headers: {
    'Accept': 'application/vnd.github.v3+json'
  }
});

export async function getRepoInfo(owner, repo) {
  return github.get(`/repos/${owner}/${repo}`);
}

export async function createIssue(owner, repo, title, body) {
  return github.post(`/repos/${owner}/${repo}/issues`, { title, body });
}

export async function listPullRequests(owner, repo, state = 'open') {
  return github.get(`/repos/${owner}/${repo}/pulls?state=${state}`);
}

These functions become callable tools in your Claude Code configuration, enabling natural conversational interactions like “Show me the open pull requests for this repository” or “Create an issue documenting this bug.”

Testing Your Wrapper

Since you’re building a utility used across multiple tools, test coverage matters. The tdd skill works well for writing tests alongside your implementation:

// api-client.test.js
import { describe, it, expect, vi } from 'vitest';
import { ApiClient } from './api-client.js';

describe('ApiClient', () => {
  it('makes GET requests correctly', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      headers: new Map([['content-type', 'application/json']]),
      json: () => Promise.resolve({ id: 1, name: 'test' })
    });

    const client = new ApiClient('https://api.example.com');
    const result = await client.get('/users/1');

    expect(result).toEqual({ id: 1, name: 'test' });
    expect(fetch).toHaveBeenCalledWith(
      'https://api.example.com/users/1',
      expect.objectContaining({ method: 'GET' })
    );
  });

  it('throws ApiError on failed requests', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 404,
      headers: new Map([['content-type', 'application/json']]),
      json: () => Promise.resolve({ message: 'Not found' })
    });

    const client = new ApiClient('https://api.example.com');
    
    await expect(client.get('/missing')).rejects.toThrow('Not found');
  });
});

Extending for Specific Use Cases

Your base wrapper adapts to specialized scenarios. For file uploads requiring multipart form data:

async uploadFile(endpoint, file, additionalFields = {}) {
  const formData = new FormData();
  formData.append('file', file);
  
  Object.entries(additionalFields).forEach(([key, value]) => {
    formData.append(key, value);
  });

  return this.request(endpoint, {
    method: 'POST',
    headers: {}, // Let browser set Content-Type for FormData
    body: formData
  });
}

This pattern extends naturally to streaming responses, webhook signatures, rate limiting detection, and other API-specific concerns you encounter when building integrations for various Claude Code workflows.

Conclusion

A fetch API wrapper transforms scattered HTTP calls into a maintainable, testable, and extensible client. Start with the basic pattern shown here, then layer in authentication, retry logic, and specialized methods as your needs grow. The investment pays off immediately through cleaner code and fewer bugs—and compounds as you build more tools that interact with external services in your Claude Code projects.

Built by theluckystrike — More at zovo.one