# Hands-on: a Python script that turns a client's OpenAPI file into tool definitions for Claude and OpenAI

> One command gets the script to draft a full set of tools. The hard work is choosing which endpoints the agent should have and writing descriptions clear enough for the model to call them correctly.

Original: https://fdetimes.net/en/guides/python-openapi-to-llm-tool-definitions/

On your first day at a client, what you often get is a link to an `openapi.json` file with a few hundred endpoints and a request to "let the agent use this system". Writing each tool definition by hand takes a week and invites mistakes. A Python script of about a hundred lines produces a draft with a single command.

But a draft is not a product. An Anthropic Engineering post warns that a common mistake is tools that simply wrap existing API endpoints. So the script in this guide takes only the repetitive work: unwinding `$ref`, gathering parameters, naming tools. That leaves your time for choosing endpoints and writing descriptions.

**What you will build:** a script, `gen_tools.py`. It reads an OpenAPI 3.x spec in JSON and outputs two lists of tools, one in Claude's format (`name`, `description`, `input_schema`) and one in OpenAI's function-calling format (`type`, `name`, `description`, `parameters`). **What you need:** Python 3 and a spec file. Convert a YAML spec to JSON first. The script uses only the standard library.

## Steps 1–2: read the spec and remove every $ref

Under the OpenAPI 3.1.0 specification, `$ref` is a URI pointing to the referenced value. An LLM cannot follow that pointer. The schema sent to the model has to be "flattened", so resolving comes first.

```python
import json, re, sys

HTTP_METHODS = {"get", "post", "put", "patch", "delete"}

def load_spec(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)

def resolve(node, spec, seen=()):
    # Simplified: only handles internal $ref of the form "#/components/..."
    if isinstance(node, dict):
        ref = node.get("$ref")
        if isinstance(ref, str) and ref.startswith("#/"):
            if ref in seen:
                return {"description": f"(recursive schema at {ref})"}
            target = spec
            for part in ref[2:].split("/"):
                target = target[part.replace("~1", "/").replace("~0", "~")]
            return resolve(target, spec, seen + (ref,))
        return {k: resolve(v, spec, seen) for k, v in node.items()}
    if isinstance(node, list):
        return [resolve(x, spec, seen) for x in node]
    return node
```

**Check:** print an operation with a complex body after resolving. Any leftover `"$ref"` string is a relative reference to another file. The specification allows that kind of reference, but this cut-down version does not handle it.

The `seen` variable prevents infinite loops when a schema points back to itself, such as a `Category` whose list of `children` is also `Category`.

## Step 3: merge parameters from several places into one schema

OpenAPI puts parameters in four locations: `query`, `header`, `path` and `cookie`. The data sent up sits separately in `requestBody`. Claude has only one `input_schema`, a single JSON Schema describing the parameters the tool expects, so the script has to gather everything into one object.

```python
SKIP_IN = {"header", "cookie"}  # your code attaches auth; don't let the model fill it in

def build_input_schema(op, path_item):
    props, required = {}, []
    params = path_item.get("parameters", []) + op.get("parameters", [])
    for p in params:
        if p.get("in") in SKIP_IN:
            continue
        schema = dict(p.get("schema", {}))
        if p.get("description"):
            schema["description"] = p["description"]
        props[p["name"]] = schema
        if p.get("required"):
            required.append(p["name"])
    body = op.get("requestBody", {})
    content = body.get("content", {}).get("application/json", {})
    if "schema" in content:
        props["body"] = content["schema"]
        if body.get("required"):
            required.append("body")
    return {"type": "object", "properties": props, "required": required}
```

Two decisions here are worth revisiting when you face a real system. Skipping headers and cookies is deliberate: authentication tokens should be attached by your code, not filled in by the model.

The second decision is how to handle name clashes. Picture an endpoint `PATCH /orders/{id}` with `id` in the path and an `id` field in the body as well: wrapping the body under a `body` key keeps the two from overwriting each other. Anthropic Engineering advises that parameter names be clear and unambiguous, so if the spec uses names like `q` or `p1`, rename them at this step.

## Step 4: tool names must be valid, descriptions must not be empty

Claude requires `name` to match the regex `^[a-zA-Z0-9_-]{1,128}$`. Under the OpenAPI 3.1.0 specification, `operationId` must be unique across the whole API, which makes it the natural candidate for a tool name. Still, Redocly notes that tools rely on `operationId` to identify operations, and real-world specs often omit or duplicate the field, so the script needs a fallback.

```python
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")

def tool_name(op, method, path, used, prefix):
    raw = op.get("operationId") or f"{method}_{path}"
    name = (prefix + re.sub(r"[^a-zA-Z0-9_-]+", "_", raw).strip("_"))[:128]
    base, i = name, 2
    while name in used:
        suffix = f"_{i}"
        name = base[:128 - len(suffix)] + suffix
        i += 1
    assert NAME_RE.match(name), name
    used.add(name)
    return name

def tool_description(op, method, path):
    text = " ".join(s.strip() for s in (op.get("summary"), op.get("description")) if s)
    return text or f"Calls {method.upper()} {path}. NEEDS DESCRIPTION."
```

`prefix` follows Anthropic's recommendation: prefix tools by service, for example `crm_` or `billing_`, so the model can tell tools apart when an agent connects to several systems at once. On descriptions, Anthropic's documentation calls them by far the most important factor in tool performance and advises at least 3–4 sentences per tool.

Client specs rarely reach that level. The `NEEDS DESCRIPTION` string is planted so you can grep for it and never ship a tool with an empty description to production by mistake.

**Key point:** The machine can produce the draft, but choosing which tools to keep and how to describe them is still the engineer's job.

## Step 5: one source, two output formats

OpenAI function calling uses `type`, `name`, `description`, `parameters` and `strict`. The same schema prepared above can be output in both formats without recomputing anything.

```python
def main(spec_path, prefix="", allow=None):
    spec = load_spec(spec_path)
    used, claude, openai, strict_candidates = set(), [], [], []
    for path, item in spec.get("paths", {}).items():
        item = resolve(item, spec)
        for method, op in item.items():
            if method not in HTTP_METHODS:
                continue
            if allow and op.get("operationId") not in allow:
                continue
            name = tool_name(op, method, path, used, prefix)
            desc = tool_description(op, method, path)
            schema = build_input_schema(op, item)
            claude.append({"name": name, "description": desc, "input_schema": schema})
            openai.append({"type": "function", "name": name,
                           "description": desc, "parameters": schema})
            # Partial check: only the outermost level, not schemas nested in the body
            if set(schema["required"]) == set(schema["properties"]):
                strict_candidates.append(name)
    json.dump({"claude": claude, "openai": openai}, sys.stdout,
              ensure_ascii=False, indent=2)
    print("Strict candidates, need further checks:", strict_candidates, file=sys.stderr)

if __name__ == "__main__":
    allow = set(sys.argv[3].split(",")) if len(sys.argv) > 3 else None
    main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "", allow)
```

```bash
python3 gen_tools.py openapi.json crm_ listOrders,getOrder,updateOrder > tools.json
```

**Check:** count the tools in `tools.json`, the number flagged as missing a description, and the list of strict candidates printed to stderr. The next step shows what one specific tool looks like.

## Step 6: follow one operation from spec to JSON

Take a hypothetical spec to try it out. The endpoint `PATCH /orders/{id}` has the `operationId` `updateOrder`, one path parameter and a body pointing to the `OrderPatch` schema:

```json
"/orders/{id}": {
  "patch": {
    "operationId": "updateOrder",
    "summary": "Update an order.",
    "description": "Changes the status or adds an internal note.",
    "parameters": [
      {"name": "id", "in": "path", "required": true,
       "description": "Order ID", "schema": {"type": "string"}},
      {"name": "X-Api-Key", "in": "header", "required": true,
       "schema": {"type": "string"}}
    ],
    "requestBody": {"required": true, "content": {"application/json": {
      "schema": {"$ref": "#/components/schemas/OrderPatch"}}}}
  }
}
```

In `components/schemas`, assume `OrderPatch` is an object with two optional fields: `status` (string, enum `pending`, `shipped`, `refunded`) and `note` (string). Run the script with the prefix `crm_`, and the matching element in the `claude` array will be:

```json
{
  "name": "crm_updateOrder",
  "description": "Update an order. Changes the status or adds an internal note.",
  "input_schema": {
    "type": "object",
    "properties": {
      "id": {"type": "string", "description": "Order ID"},
      "body": {
        "type": "object",
        "properties": {
          "status": {"type": "string", "enum": ["pending", "shipped", "refunded"]},
          "note": {"type": "string"}
        }
      }
    },
    "required": ["id", "body"]
  }
}
```

The element in the `openai` array carries the same `name`, `description` and schema; only the wrapper differs: it adds `"type": "function"` and puts the schema under the `parameters` key instead of `input_schema`.

Reading this output, you can verify four things. The `$ref` has gone and `OrderPatch` has been copied straight into `body`. The `X-Api-Key` header does not appear, as intended. The description of the `id` parameter has moved into its schema.

The fourth is that the tool description has only two sentences, short of the 3–4 Anthropic recommends. A good rewrite would say when to call this tool, what each `status` value leads to and what the tool cannot do.

## Why the script does not enable strict on its own

The tool above makes the strict-candidate list, because at the outer level `required` matches `properties` exactly. But inside `body`, neither `status` nor `note` is in `required`, while OpenAI's strict mode requires every field in `properties` to be marked `required`. The comparison in the code looks only at the outermost level, so it cannot see this problem.

So treat the candidate list as a starting point.

Optional fields such as `note` have to be converted by hand before you add `"strict": true` manually.

Claude is simpler: set `strict: true` on the tool definition to turn on schema checking. If you want to generate `input_examples` from the `example` fields in the spec, validate each example against `input_schema` before sending, because an invalid example makes the API return a 400 error.

## The biggest trap is generating too many tools

Run it without an allowlist on a hundred-endpoint spec and you get a hundred tools. OpenAI advises keeping fewer than 20 functions at the start of each turn. It is soft guidance, but there is a reason for it: Anthropic also argues that fewer tools, each doing more, leave the model less unsure when choosing.

So the `allow` parameter is not a side feature; it is the main job. Imagine the client needs an agent to handle refund requests: three operations to look up orders, view details and update an order are enough, plus one combined tool if the process always follows a fixed sequence.

A few other mistakes are common. Some people let authentication headers slip into the schema. Some ship tools with empty descriptions. Some trust that `operationId` is always unique without running a check.

At the client, before writing any code, ask who owns the spec and whether it matches the API actually running. Running a linter such as Redocly to catch duplicate operationIds tells you quickly how far the spec can be trusted.

If you want this skill on your CV, don't write "integrated OpenAPI with LLMs". Write that you cut a spec of hundreds of endpoints down to a small tool set, with descriptions you wrote yourself and a schema test suite, then put the script on GitHub running against a public spec.

In FDE interviews, talk about which tools you cut and why, rather than boasting about how many tools you generated.

Running the script takes one command. The work the client actually pays for is the afternoon that follows, when you decide which tools to keep.

**Try this week:**

- Run the script on a public OpenAPI spec, then count how many tools are flagged NEEDS DESCRIPTION.
- Pick at most 10 operations that serve a real use case, and write your own description for each tool, at least 3–4 sentences.
- Look at the tools the script suggests could use OpenAI's strict mode, then check each one, including schemas nested in the body, against the full strict-mode requirements in OpenAI's documentation.

## Sources

- [Define tools (Claude Platform docs)](https://platform.claude.com/docs/en/docs/agents-and-tools/tool-use/implement-tool-use)

- [Function calling (OpenAI API docs)](https://developers.openai.com/api/docs/guides/function-calling)

- [OpenAPI Specification v3.1.0](https://spec.openapis.org/oas/v3.1.0.html)

- [operation-operationId-unique (Redocly CLI)](https://redocly.com/docs/cli/rules/oas/operation-operationId-unique)

- [Writing effective tools for agents: with agents](https://www.anthropic.com/engineering/writing-tools-for-agents)
