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.
In brief
- operationId is the natural candidate for a tool name, but the script must still name tools itself when the spec has missing or duplicate operationIds.
- The description matters more than anything else for tool performance, so the script takes both summary and description and flags tools that still have none.
- Automatic generation is only a draft: filter down to a few tools and merge related operations before handing them to an agent.
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.
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.
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.
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.
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.
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)
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:
"/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:
{
"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.
Was this article useful?
Thanks for the feedback!