A good pattern is to separate the public tool surface (5 simple endpoints the model sees) from the implementation library (100s of operations the model never reasons about directly).
Think of /tools as a filesystem rather than an API.
/tools
├── customers/
│ ├── create.yaml
│ ├── update.yaml
│ ├── delete.yaml
│ ├── search.yaml
│ ├── _shared.yaml
│ └── schemas/
├── invoices/
│ ├── create.yaml
│ ├── send.yaml
│ ├── refund.yaml
│ └── ...
├── inventory/
├── orders/
├── shipping/
└── auth/
The LLM never loads this entire tree.
Instead, expose only something like:
GET /tools
{
"tools": [
"discover",
"describe",
"invoke",
"search",
"help"
]
}
Then those endpoints become the entire protocol.
Option 1: Filesystem discovery (my favorite)
discover(path)
Returns directory listings.
discover("/")
↓
{
"directories":[
"customers",
"orders",
"inventory"
]
}
Then
discover("/orders")
↓
{
"operations":[
"create",
"cancel",
"refund",
"ship"
]
}
Only when needed:
describe("/orders/refund")
returns
name: refund_order
description: Refund an order
arguments:
order_id:
type: string
amount:
type: number
examples:
...
The model only ever sees the schema it currently needs.
Option 2: Lazy imports
Internally:
tools/
crm/
accounting/
logistics/
The runtime does
tool = load("/crm/customers/create")
instead of
load_everything()
which keeps context tiny.
Option 3: Semantic search
The model says
I need to refund an order.
POST /search
{
"query":"refund an order"
}
returns
[
"/orders/refund",
"/payments/refund",
"/subscriptions/refund"
]
Then
describe("/orders/refund")
Then
invoke(...)
No directory traversal required.
Option 4: Namespaces
customers/create
customers/update
orders/refund
orders/search
orders/create
The namespace is simply the folder.
The implementation can have
orders/
refund/
schema.json
execute.py
docs.md
tests/
without exposing any of that.
Option 5: Progressive disclosure
Instead of dumping a 200-field schema:
describe("/orders/create")
returns
{
"required":[
"customer",
"items"
],
"optional_count":96
}
If needed
describe("/orders/create", section="shipping")
↓
Only shipping fields.
Then
describe("/orders/create", section="taxes")
Only tax fields.
This avoids enormous schemas.
Option 6: Bundle related operations
Instead of
120 endpoints
Have
invoke()
accept
tool:
orders.create
Internally
orders/
create.yaml
create.py
orders/
cancel.yaml
cancel.py
The runtime loads only that one folder.
Option 7: Capability graph
/
sales
crm
accounting
Each directory has metadata
title: CRM
contains:
customers
companies
contacts
The model walks the graph exactly like browsing files.
Option 8: Hidden implementation tree
tools/
orders/
create/
schema.json
prompt.md
examples.json
executor.py
auth.py
validation.py
Visible to the model:
orders/create
Invisible:
executor.py
validation.py
examples.json
etc
I'd avoid exposing OpenAPI
Even though you can expose a 2 MB OpenAPI document, models generally perform better when they load only the relevant operation. Huge specs increase latency, consume context, and make tool selection less reliable.
Architecture I'd build
+----------------------+
| LLM |
+----------+-----------+
|
discover("/")
|
+----------------+----------------+
| |
customers/ orders/
| |
discover("orders") discover(...)
|
refund
create
cancel
|
describe("orders/refund")
|
invoke(...)
|
implementation
|
orders/
refund/
schema.json
executor.py
docs.md
auth.py
validators/
examples/
This gives you a stable, minimal protocol with only five public endpoints—discover, search, describe, invoke, and help—while allowing the backing implementation to grow to hundreds of operations and parameters. The model only loads the directories and schemas it needs for the current task, keeping prompts compact and reducing cognitive overhead.