What if an AI agent could build its own API tools? Meta-tooling with Python, OpenAPI, Strands Agents, and Bedrock

Most AI agent demos start with a comfortable assumption: we, the developers, already know which tools the agent will need. We write a get_customer tool, a create_ticket tool, maybe a query_database tool, and then the model decides when to use them.

That works, but real systems are not always that tidy. Internal APIs change. Teams expose new endpoints. A support agent may need to operate a service it has never seen before. So I wanted to test a slightly different idea:

What if the agent did not start with business tools at all?

That is my PoC. A small command-line application where a Strands agent starts only with meta-tools. It can inspect an OpenAPI contract, generate Python tools for the API operations, load those tools at runtime, and then use them to complete a task.

The example API is deliberately simple: a tiny commerce support API with delayed orders and discount vouchers. The interesting part is not the API. The interesting part is the sequence:

  1. Read the OpenAPI spec.
  2. Generate Strands-compatible Python tools.
  3. Load those tools dynamically.
  4. Use the newly created tools to solve the user request.

The agent begins without list_orders or create_discount_voucher. It builds them when it discovers that it needs them.

The idea

The local demo task is:

poetry run toolsmith agent \
"Find delayed orders in Spain and create a 15 percent discount voucher for each affected customer."

The agent receives the OpenAPI file and a tools directory. Its first tools are not business tools. They are tools for creating tools:

@tool
def inspect_openapi(spec_path: str) -> dict:
"""Inspect an OpenAPI file and list the operations available for tool generation."""
return describe_openapi(spec_path)
@tool
def generate_openapi_tools(spec_path: str, output_dir: str) -> dict:
"""Generate Strands Python tools from an OpenAPI file into the selected directory."""
generated = generate_tools(spec_path, output_dir)
return {
"created": [
{
"name": item.name,
"path": str(item.path),
"method": item.operation.method,
"api_path": item.operation.path,
}
for item in generated
]
}

The third tool is Strands’ load_tool. Once the Python files exist, the agent can load them into its own runtime and use them like any other tool.

This is the meta-tooling pattern: the agent does not only use capabilities; it can create new capabilities while it is running.

Project structure

The project is intentionally small:

src/
  meta/
    cli.py
    settings.py
    demo_api.py
    agent/
      factory.py
      prompts.py
    meta_tooling/
      generator.py
      models.py
      openapi.py
    runtime/
      http_client.py
examples/
  support/
    openapi.yaml
tests/

The responsibilities are explicit:

  • meta_tooling/openapi.py reads the OpenAPI contract and extracts operations.
  • meta_tooling/generator.py writes Python files that expose those operations as Strands tools.
  • runtime/http_client.py contains the deterministic HTTP boundary used by generated tools.
  • agent/factory.py wires Strands Agents with Bedrock and the meta-tools.
  • demo_api.py runs a tiny local HTTP API for the example.
  • cli.py exposes the commands.

The OpenAPI contract

The demo contract describes three operations:

paths:
/orders:
get:
operationId: list_orders
summary: List customer orders
parameters:
- name: status
in: query
required: false
schema:
type: string
- name: country
in: query
required: false
schema:
type: string
/orders/{order_id}:
get:
operationId: get_order
summary: Get one order
/discount-vouchers:
post:
operationId: create_discount_voucher
summary: Create a discount voucher for a customer order

The CLI can inspect it without calling an LLM:

poetry run toolsmith inspect

Output:

Support Commerce API (1.0)

Tool                     Method   Path                 Summary
list_orders              GET      /orders              List customer orders
get_order                GET      /orders/{order_id}   Get one order
create_discount_voucher  POST     /discount-vouchers   Create a discount voucher for a customer order

Generating tools

The generator turns each OpenAPI operation into a Python module. This can also be run directly:

poetry run toolsmith build

It creates files like:

generated_tools/
  __init__.py
  list_orders.py
  get_order.py
  create_discount_voucher.py

The generated list_orders tool looks like this:

from __future__ import annotations
from typing import Any
from strands import tool
from meta.runtime.http_client import request_api
@tool
def list_orders(status: str | None = None, country: str | None = None) -> dict[str, Any]:
"""List customer orders
Generated API tool for `GET /orders`.
Returns customer orders filtered by status or country.
"""
return request_api(
method="GET",
path_template="/orders",
path_params={},
query_params={"status": status, "country": country},
json_body=None,
)

There is no magic in the HTTP call itself. It is just Python:

def request_api(
*,
method: str,
path_template: str,
path_params: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
timeout: int = 15,
) -> dict[str, Any]:
base_url = os.getenv("TOOLSMITH_API_BASE_URL", "http://127.0.0.1:8010")
path = _build_path(path_template, path_params or {})
query = {key: value for key, value in (query_params or {}).items() if value is not None}
response = requests.request(
method=method,
url=f"{base_url.rstrip('/')}{path}",
params=query,
json=json_body,
timeout=timeout,
)

That boundary is important. The LLM can decide which operation it needs, but the generated tool uses deterministic Python for the actual API call.

The agent

The agent is created with Bedrock and Strands:

def create_agent(settings: Settings) -> Agent:
session_kwargs = {"region_name": settings.aws_region}
if settings.aws_profile:
session_kwargs["profile_name"] = settings.aws_profile
boto_session = boto3.Session(**session_kwargs)
model = BedrockModel(
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
boto_session=boto_session,
temperature=0.2,
)
return Agent(
system_prompt=TOOLSMITH_SYSTEM_PROMPT,
model=model,
tools=[inspect_openapi, generate_openapi_tools, load_tool],
)

The prompt is intentionally strict:

TOOLSMITH_SYSTEM_PROMPT = """
You are an API toolsmith agent.
You start with meta-tools, not with business tools. Your job is to inspect an
OpenAPI contract, generate Python tools for the useful operations, load those
tools, and then use them to answer the user's question.
Rules:
- Inspect the OpenAPI file before generating tools.
- Generate tools only inside the configured generated tools directory.
- Load generated tools before trying to call them.
- Prefer deterministic API calls through generated tools over guessing.
- Explain briefly which tools you created and why they were useful.
"""

The model-driven part is deciding the workflow. The deterministic part is parsing OpenAPI, writing the Python modules, and making HTTP requests.

Running the demo

Install dependencies with Poetry:

poetry install

Run the local demo API in one terminal:

poetry run toolsmith demo-api

The API listens on:

http://127.0.0.1:8010

In another terminal, inspect the OpenAPI contract:

poetry run toolsmith inspect

Generate tools manually:

poetry run toolsmith build

Or let the agent do the generation and tool loading:

AWS_PROFILE=sandbox AWS_REGION=us-west-2 \
poetry run toolsmith agent \
"Find delayed orders in Spain and create a 15 percent discount voucher for each affected customer."

The expected behavior is:

  1. The agent inspects examples/support/openapi.yaml.
  2. It generates list_orders, get_order, and create_discount_voucher.
  3. It loads the generated tools.
  4. It calls list_orders(status="delayed", country="ES").
  5. It creates vouchers for the affected orders.

The generated tool can also be called directly from Python:

from pathlib import Path
from importlib.util import module_from_spec, spec_from_file_location
from meta.meta_tooling.generator import generate_tools_from_openapi
out = Path("generated_tools")
generate_tools_from_openapi("examples/support/openapi.yaml", out)
spec = spec_from_file_location("list_orders", out / "list_orders.py")
module = module_from_spec(spec)
spec.loader.exec_module(module)
print(module.list_orders(status="delayed", country="ES"))

Output:

{
"status_code": 200,
"ok": True,
"body": {
"orders": [
{"id": "ORD-1001", "customer": "Ada Lovelace", "country": "ES", "status": "delayed", "total": 189.9, "days_late": 4},
{"id": "ORD-1003", "customer": "Barbara Liskov", "country": "ES", "status": "delayed", "total": 244.0, "days_late": 7},
]
},
}

Tech stack

  • Python 3.13 with Poetry.
  • Strands Agents for agent orchestration and dynamic tool loading.
  • AWS Bedrock for the LLM runtime.
  • OpenAPI as the API capability source.
  • Click for the CLI.
  • Rich for terminal output.
  • Requests for deterministic HTTP calls.
  • pytest for tests.

A couple of notes

This is not an API gateway and it is not a replacement for careful integration design. It is a PoC for a specific agentic pattern: start with a narrow set of trusted meta-tools, then allow the agent to expand its own capabilities from a machine-readable contract.

The generated tools are intentionally boring. That is a feature. The agent can be creative in planning, but the API calls should be predictable, inspectable, and testable.

The next step would be to add stronger guardrails: endpoint allowlists, authentication strategies, versioned tools, and a review step before loading newly created code.

And that’s all. Full source code available on my GitHub.

Leave a Reply