OpenAPI Client Generation skill for Codex

Typed clients from the spec, regenerated in one command. Codex makes the OpenAPI spec generator-ready first, using a bundled checker for missing or duplicate operationIds, inline schemas and broken refs, then picks the lightest generator for the stack and pins it. Auth, retries and error mapping live in a wrapper instead of edited generated files, and CI fails when the committed client drifts from the spec.

  • Integrations
  • $openapi-client-generation
  • 177 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- openapi-client-generation

Installs to ~/.agents/skills/openapi-client-generation/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $openapi-client-generation, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Generates typed API clients from OpenAPI 3.x specs - openapi-typescript with openapi-fetch, Hey API, Orval for React Query, openapi-python-client, oapi-codegen for Go, Kiota and OpenAPI Generator - with spec linting, stable operationIds, pinned generator versions, auth and retry wrappers, and CI drift checks. Use when asked to generate an SDK or client from a swagger or OpenAPI file, replace hand-written fetch calls with typed ones, regenerate after an API change, or fix generated code that has bad names or any types.

Use it when

  • Turning an OpenAPI or Swagger file into a typed client or SDK
  • Replacing hand-written HTTP calls with generated, type-checked ones
  • Regenerating a client after the API changed
  • Generated code has unstable names, any types or missing models

Use something else when

How to check the result

The readiness script reports no errors, the app type-checks against the regenerated client without new casts, and the CI drift check passes on a clean checkout.

The mistake it prevents

Hand-editing generated files to fix a type, which the next regeneration silently erases.

~/.agents/skills/openapi-client-generation/SKILL.md

--- name: openapi-client-generation description: Generates typed API clients from OpenAPI 3.x specs - openapi-typescript with openapi-fetch, Hey API, Orval for React Query, openapi-python-client,… license: MIT ---

Done well means: the generated client compiles with no any/unknown holes on the endpoints the app uses, names are stable across regenerations because the spec has explicit operationIds, the generator version and spec snapshot are pinned, regeneration is one command, and CI fails when the committed client drifts from the spec.

Inspect first

  1. Read AGENTS.md for language, package manager, and where generated code should live.
  2. Locate the spec: a repo file, a URL (/openapi.json, /v3/api-docs, /swagger.json), or a vendor download. Fetching remote specs needs network; save a copy into the repo either way.
  3. Check openapi: version. Swagger 2.0 must be converted to 3.x first (for example with swagger2openapi) or many generators produce poor types.
  4. Look for an existing generated client and its generator (rg -n "openapi-generator|orval|hey-api|openapi-typescript|oapi-codegen" -g '!node_modules'). Regenerate with the same tool unless the user wants a switch.

Make the spec generator-ready

npx @redocly/cli lint openapi.yaml          # or: npx @stoplight/spectral-cli lint openapi.yaml
python3 scripts/codegen_readiness.py openapi.json

codegen_readiness.py (standard library; YAML needs PyYAML or a JSON bundle first) reports missing or duplicate operationIds, non-identifier ids, inline object schemas, unresolved local $refs and operations without a 2xx response. Exit code 1 means errors.

Fixes, in order of impact:

FindingEffect on generated codeFix
Missing operationIdNames like getApiV1UsersId that change when paths changeAdd operationId: getUser
Inline object schemasAnonymous types (InlineResponse2001)Move to components/schemas with a name
No 2xx schemaReturn type unknown / voidDescribe the success body
nullable vs type: [x, "null"] mixupsWrong optionalityMatch the spec’s 3.0 or 3.1 dialect
Missing required arraysEvery field optionalList required properties
Polymorphism without discriminatorUnion types that cannot narrowAdd discriminator.propertyName

If the spec belongs to a third party, do not edit it in place. Keep the vendor file untouched and apply fixes with an overlay (redocly decorators, an OpenAPI Overlay file, or a small patch script) so the next vendor update is a clean replace.

Choose a generator

Stack and needDefaultOutput
TypeScript, minimal runtime, keep native fetchopenapi-typescript + openapi-fetchTypes only + tiny typed fetch wrapper
TypeScript SDK with functions per operation, optional Zod / TanStack Query@hey-api/openapi-tsGenerated SDK functions and types
React app wanting ready-made TanStack Query hooks and MSW mocksOrvalHooks per operation
Pythonopenapi-python-client (httpx + attrs); datamodel-code-generator for Pydantic models onlyPackage
Gooapi-codegenTypes + client in one file
.NET, Java, many languages from one specKiota or OpenAPI Generator (needs Java)Full SDK

Prefer the lightest option that types the calls the app makes; heavy SDK generators add runtime classes that are hard to review.

TypeScript: openapi-typescript + openapi-fetch

npx openapi-typescript ./openapi/api.yaml -o ./src/api/schema.d.ts
// src/api/client.ts
import createClient, { type Middleware } from 'openapi-fetch';
import type { paths } from './schema';

const auth: Middleware = {
  async onRequest({ request }) {
    request.headers.set('Authorization', `Bearer ${await getToken()}`);
    return request;
  },
};

export const api = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL });
api.use(auth);

// usage: path, params and body are checked against the spec
const { data, error, response } = await api.GET('/users/{id}', { params: { path: { id: userId } } });
if (error) throw new ApiError(response.status, error);

TypeScript: Hey API

// openapi-ts.config.ts
import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: './openapi/api.yaml',
  output: 'src/client',
  plugins: ['@hey-api/client-fetch', '@tanstack/react-query'],
});

Run with npx @hey-api/openapi-ts. Pin the exact version in package.json; the generator’s output changes between minor versions.

Python and Go

uvx openapi-python-client generate --path openapi/api.yaml --output-path clients/acme_api --overwrite
# oapi-codegen.yaml
package: acmeapi
output: internal/acmeapi/client.gen.go
generate:
  models: true
  client: true
go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest   # Go 1.24+ tool directive
go tool oapi-codegen -config oapi-codegen.yaml openapi/api.yaml

Wrap, do not edit, generated code

  • Never hand-edit generated files; the next run erases the change. Put auth, retries, base URL, telemetry and error mapping in a thin wrapper or middleware.
  • Retries: only for idempotent methods (GET, PUT, DELETE) or requests with an idempotency key; back off on 429 and 503 honouring Retry-After.
  • Map error responses to one error type the app understands; keep the raw status and body.
  • Mark generated folders in .gitattributes (src/client/** linguist-generated=true) so reviews collapse them, and exclude them from lint and coverage.

Regeneration workflow

{
  "scripts": {
    "api:fetch": "curl -fsSL https://api.example.com/openapi.json -o openapi/api.json",
    "api:gen": "openapi-typescript openapi/api.json -o src/api/schema.d.ts",
    "api:check": "npm run api:gen && git diff --exit-code -- src/api/"
  }
}
  • Fetch or update the spec snapshot (network) and commit it with the generated output.
  • Lint and run codegen_readiness.py.
  • Generate with the pinned version.
  • Type-check the whole app (tsc --noEmit, mypy, go build ./...): breaking API changes show up as compile errors at call sites; fix them in the same PR.
  • Run api:check in CI so a spec change without regeneration fails the build.

Verification

  • Compile: the app type-checks against the new client with no new any or // @ts-ignore.
  • Contract: call one read endpoint against a dev server or recorded fixture and confirm the response parses into the generated type (with Zod or Pydantic validation where enabled).
  • Diff review: read the generated diff summary; renamed functions usually mean an operationId changed.
  • When a type is wrong, fix the spec (or overlay) and regenerate; do not cast at call sites.

Gotchas

  • additionalProperties defaults to allowed in OpenAPI; some generators then emit index signatures that weaken types. Set it explicitly when the shape is closed.
  • format: date-time becomes string in most TS generators; convert at the wrapper boundary if the app needs Date.
  • Large integers (int64) overflow JavaScript numbers; keep them as strings in the spec when IDs exceed 2^53.
  • Tags drive file splitting in some generators; untagged operations land in a catch-all file.
  • A server spec generated from code (FastAPI, springdoc, NestJS) may differ per environment; snapshot from the deployed version the client targets.

Output

Report: spec source and version, generator and pinned version, files generated, spec fixes or overlays applied, wrapper features, call sites changed, and the CI drift check added.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "OpenAPI Client Generation"
  short_description: "Typed clients from the spec, regenerated in one command"
  default_prompt: "$openapi-client-generation Generate a typed TypeScript client from our openapi.yaml and replace the raw fetch calls"
scripts/codegen_readiness.py123 lines, 4.5 KB
#!/usr/bin/env python3
"""Check an OpenAPI 3.x document for problems that produce bad generated clients.

Usage: python3 codegen_readiness.py <spec.json | spec.yaml>
YAML needs PyYAML if installed; otherwise convert first, for example
  npx @redocly/cli bundle openapi.yaml --ext json -o openapi.json

Checks: missing or duplicate operationIds, operationIds that are not identifiers,
inline object schemas in request/response bodies (anonymous generated types),
unresolved local $refs, and missing 2xx responses. Exits 1 on errors.
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def load(path: Path):
    text = path.read_text(encoding="utf-8")
    if path.suffix.lower() in (".yaml", ".yml"):
        try:
            import yaml  # type: ignore
        except ImportError:
            sys.exit("error: PyYAML not installed; convert the spec to JSON first (see --help)")
        return yaml.safe_load(text)
    return json.loads(text)


def resolve(doc, ref: str):
    if not ref.startswith("#/"):
        return True  # external refs are out of scope
    node = doc
    for part in ref[2:].split("/"):
        part = part.replace("~1", "/").replace("~0", "~")
        if not isinstance(node, dict) or part not in node:
            return False
        node = node[part]
    return True


def walk_refs(doc, node, where, errors):
    if isinstance(node, dict):
        ref = node.get("$ref")
        if isinstance(ref, str) and not resolve(doc, ref):
            errors.append(f"{where}: unresolved $ref {ref}")
        for k, v in node.items():
            walk_refs(doc, v, f"{where}/{k}", errors)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            walk_refs(doc, v, f"{where}[{i}]", errors)


def inline_object(schema) -> bool:
    if not isinstance(schema, dict) or "$ref" in schema:
        return False
    if schema.get("type") == "object" and schema.get("properties"):
        return True
    if schema.get("type") == "array":
        return inline_object(schema.get("items"))
    return False


def main(argv: list[str]) -> int:
    if len(argv) != 1 or argv[0] in ("-h", "--help"):
        print(__doc__.strip())
        return 2
    doc = load(Path(argv[0]))
    errors: list[str] = []
    warnings: list[str] = []

    version = str(doc.get("openapi", ""))
    if not version.startswith("3."):
        errors.append(f"expected an OpenAPI 3.x document, got openapi={version or 'missing'} (Swagger 2.0 needs conversion)")

    seen: dict[str, str] = {}
    ops = 0
    for path, item in (doc.get("paths") or {}).items():
        if not isinstance(item, dict):
            continue
        for method in METHODS:
            op = item.get(method)
            if not isinstance(op, dict):
                continue
            ops += 1
            label = f"{method.upper()} {path}"
            op_id = op.get("operationId")
            if not op_id:
                errors.append(f"{label}: missing operationId (generators invent unstable names)")
            else:
                if op_id in seen:
                    errors.append(f"{label}: duplicate operationId '{op_id}' (also {seen[op_id]})")
                seen[op_id] = label
                if not IDENT.match(op_id):
                    warnings.append(f"{label}: operationId '{op_id}' is not a plain identifier")
            responses = op.get("responses") or {}
            if not any(str(code).startswith("2") for code in responses):
                warnings.append(f"{label}: no 2xx response defined; return type will be unknown")
            for code, resp in responses.items():
                for mt, media in ((resp or {}).get("content") or {}).items():
                    if inline_object((media or {}).get("schema")):
                        warnings.append(f"{label} {code} {mt}: inline object schema; move it to components/schemas")
            body = ((op.get("requestBody") or {}).get("content") or {})
            for mt, media in body.items():
                if inline_object((media or {}).get("schema")):
                    warnings.append(f"{label} requestBody {mt}: inline object schema; move it to components/schemas")

    walk_refs(doc, doc, "#", errors)

    for w in warnings:
        print(f"warn:  {w}")
    for e in errors:
        print(f"error: {e}")
    print(f"\n{ops} operations, {len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc