Wrapping an API That Has No CLI

skills/writing-skills/reference/api-wrappers.md · view on GitHub (opens in a new tab)

A recurring skill shape: an API (usually REST) has no official CLI or MCP server, but Claude needs to call it repeatedly. Bundle one script that exposes the high-frequency operations as readable subcommands plus a generic passthrough for everything else, then describe that surface in SKILL.md so Claude knows what to call and when.

Contents

When to use this pattern (and when not to)

Use it when no existing tool fits: the vendor ships no CLI, there’s no MCP server, and Claude would otherwise hand-roll curl with the right auth headers on every call.

First check the alternatives — they’re usually better if available:

Build a wrapper script only when those don’t exist or don’t cover what you need. The script turns “Claude reconstructs auth + base URL + JSON shaping each time” (fragile, token-heavy, inconsistent) into “Claude runs one documented command” (reliable, cheap, consistent) — exactly the payoff of bundling scripts.

Anatomy: named subcommands + a generic passthrough

Two layers, mirroring the “one default with an escape hatch” principle:

  1. Abstracted subcommands for the few highest-frequency operations — get-issue, list-issues, create-issue. These encode the path, method, and any output shaping so Claude (and you) read intent, not URLs.
  2. A generic api <METHOD> <path> [body] passthrough that structurally invokes any endpoint. This is the escape hatch: you don’t need a subcommand for every route, and Claude can reach endpoints you never anticipated.

Don’t try to wrap the whole API. Wrap the 80% you use by name; let the passthrough cover the long tail.

Script structure

Annotated example script

scripts/api.sh — a thin wrapper over a fictional REST API:

#!/usr/bin/env bash
# Thin wrapper around the Example REST API.
#   Auth: EXAMPLE_API_TOKEN  (required)
#   Base: EXAMPLE_API_BASE   (optional; default https://api.example.com/v1)
set -euo pipefail

BASE="${EXAMPLE_API_BASE:-https://api.example.com/v1}"
: "${EXAMPLE_API_TOKEN:?Set EXAMPLE_API_TOKEN to a valid API token}"

# Core: every subcommand routes through here so auth + error handling live once.
# --fail-with-body exits non-zero on HTTP >=400 while still printing the body,
# so failures surface with their status instead of looking like success.
api() {
  local method="$1" path="$2" body="${3:-}"
  local args=(-sS --fail-with-body -X "$method"
    -H "Authorization: Bearer $EXAMPLE_API_TOKEN"
    -H "Content-Type: application/json")
  [[ -n "$body" ]] && args+=(-d "$body")
  curl "${args[@]}" "$BASE$path"
}

case "${1:-help}" in
  # --- Abstracted common operations --------------------------------
  get-issue)    api GET "/issues/$2" ;;
  list-issues)  api GET "/issues?state=${2:-open}" | jq '[.[] | {number, title, state}]' ;;
  create-issue) api POST "/issues" "$(jq -n --arg t "$2" --arg b "${3:-}" '{title:$t, body:$b}')" ;;

  # --- Generic escape hatch: call any endpoint directly ------------
  api)          api "$2" "$3" "${4:-}" ;;

  # --- Discoverability ---------------------------------------------
  help|*)
    cat >&2 <<'EOF'
Usage: api.sh <command> [args]
  get-issue <n>                 Fetch one issue as JSON
  list-issues [state]           List issues (default: open), trimmed to key fields
  create-issue <title> [body]   Create an issue
  api <METHOD> <path> [json]    Call any endpoint directly (escape hatch)
EOF
    [[ "${1:-}" == help ]] && exit 0 || exit 2 ;;
esac

Why the choices: env-var config keeps secrets out of the skill; the api() chokepoint means a header change touches one line; jq shaping in list-issues keeps responses small for context; the * case doubles as “unknown command” (exit 2) and help (exit 0).

Describing the interaction in SKILL.md

The script is useless if Claude doesn’t know the surface. In SKILL.md:

Auth and secrets

Output shaped for the model

Robustness (errors, pagination, rate limits)