Most services publish an OpenAPI document and no MCP server. openApiTools()
turns that document into one typed tool per API operation, so an agent can call
the API without a hand-written defineTool() per endpoint and without the raw
http tool. Operations that change data ask for
approval by default.
openApiTools is exported from @lousho/build-ai-agent and
@lousho/build-ai-agent/tools. It needs no extra package. The vendor/ model
prefix chooses the provider; with OpenRouter use openrouter/<vendor>/<model>
(e.g. openrouter/openai/gpt-4o-mini).
import { createAgent, openApiTools } from '@lousho/build-ai-agent';
const tools = await openApiTools('https://api.example.com/openapi.json', {
include: ['getOrder', 'refundOrder'],
bearerToken: () => process.env.ORDERS_API_TOKEN ?? '',
});
const agent = createAgent({ model: 'openai/gpt-4o-mini', tools });
const paused = await agent.send('Refund order 1042');
// getOrder ran. refundOrder (a POST) is waiting for approval:
// agent.approvals.resolve({ id: paused.approvalId!, approved: true })openApiTools() is always async, because the document may be a URL. It returns
an array of tools you pass to createAgent({ tools }) like any other.
document can be a parsed OpenAPI 3.0 or 3.1 object, a JSON or YAML string, or
an https URL (a string or a URL). Parsing happens once, inside
openApiTools(); a tool's execute only builds and sends a request.
| Part | Value |
|---|---|
| Name | The operationId, with characters outside A-Za-z0-9_- replaced by _. Without an operationId: <method>_<path> (GET /orders/{orderId} becomes get_orders_orderId). With prefix: <prefix>__<name>. At most 64 characters. Two operations with the same name throw a ConfigurationError that lists both. |
| Description | The operation's summary, then its description (cut at 1,000 characters), then the method and path (GET /orders/{orderId}). |
| Input | An object with every path, query, header and cookie parameter as a top-level property (required as the document declares), plus body for an application/json request body. Schemas follow $refs into components, including parameters and requestBodies. OpenAPI 3.0 nullable works. A parameter whose name is already taken by another location is named <location>_<name>. |
| Result | { status, statusText, body } for every HTTP response, error statuses too, so the model can read a 404 and react. body is parsed JSON when the response says JSON, otherwise text, empty as null. |
| Errors | A network failure, a timeout and a refused redirect are tool errors. |
Accept, Content-Type and Authorization header parameters are ignored, as
the OpenAPI specification says; use bearerToken or headers for credentials.
| Option | Default | Meaning |
|---|---|---|
baseUrl |
first servers entry |
Where requests go. Overrides the document. For a document fetched from a URL, a missing servers entry means that URL's origin. |
include |
all operations | Operation names (operationId or derived name), or a function (op) => boolean. Naming an operation the document does not have throws. |
exclude |
none | Operation names to leave out. |
approval |
'mutating' |
'mutating', 'always', 'never', or (op) => boolean. |
prefix |
none | Prefix for tool names. |
headers |
none | Headers sent on every request, or a function called per request. |
bearerToken |
none | Shorthand for Authorization: Bearer <token>; a function is called per request. |
providedArguments |
none | Values your application supplies; see below. |
timeoutMs |
30000 |
Per request. |
maxResponseChars |
50000 |
The response body is cut at this length, with a note saying how much was left out. |
fetch |
global fetch |
Replaces fetch (tests, proxies). Cannot be combined with privateAddresses: 'refuse'. |
privateAddresses |
'allow' |
'refuse' makes requests and the document fetch fail for loopback, link-local and private addresses; see Security rules. Node only. |
allowPrivate |
none | With 'refuse': hosts (intranet.example, *.corp.example, an IP address) that may be or resolve to private addresses. |
op is { name, operationId?, method, path, summary?, tags }.
With approval: 'mutating', GET, HEAD and OPTIONS run, and every other method
asks. Each tool also carries MCP-style annotations: readOnlyHint: true, destructiveHint: false on GET, HEAD and OPTIONS; readOnlyHint: false on the
rest, with destructiveHint: true for DELETE. Anything that reads annotations treats these tools the way it treats MCP tools.
import { openApiTools } from '@lousho/build-ai-agent';
declare const spec: object;
// Ask for everything except two reads that are known to be harmless.
const tools = await openApiTools(spec, {
approval: (op) => !['getOrder', 'listOrders'].includes(op.name),
});providedArguments fills parameters the model should not choose, such as a
tenant id. A provided key is removed from the tool's input schema (the model
never sees it) and added to the request. The value can be a function, called per
request with the operation and the tool call id.
import { openApiTools } from '@lousho/build-ai-agent';
declare const spec: object;
declare const currentTenant: () => string;
const tools = await openApiTools(spec, {
providedArguments: {
'X-Tenant-Id': () => currentTenant(),
accountId: 'acct_123',
},
});The key is the parameter's input key: its name, or <in>_<name> (e.g.
query_id) when two parameters of an operation share a name; the request
body's key is body (requestBody when a parameter already took body). A
key that no selected operation has throws, so a typo does not silently expose
the parameter to the model.
- The base URL is yours, not the model's. The model chooses parameter and
body values; it never chooses the host. The base URL must be https. Plain
http is accepted only for
localhost,127.0.0.1and[::1], and a URL with a user name or password is refused. The same rule applies to a document URL. - Credentials stay out of what the model and your logs see. Headers and
tokens are never part of a tool's input schema, its
tool.startandtool.doneevents or the transcript; only the model's own input and the response are. A header the model supplies for a declared header parameter never replacesheadersorbearerToken. - Redirects stay on the base origin. Requests use manual redirects: at most 3 are followed, and only to the same origin as the base URL. A redirect to another origin is a tool error, so a token never leaves the configured origin.
- Path values stay in their segment. Path parameters are percent-encoded
(
../admincannot climb out of its segment), and a value of exactly.or..is refused. - Private addresses: off by default,
privateAddresses: 'refuse'to check. By default the tools call the base URL you configured, wherever it resolves: base URLs are yours and often internal. When the base URL or the document URL is not fully under your control (a name someone else's DNS answers, a document you fetch from elsewhere), passprivateAddresses: 'refuse'. Then every operation request and the document fetch, each redirect hop included, is refused when its host is or resolves to a loopback, link-local, private, shared, multicast or reserved address (the listhttp_requestuses, NAT64 and 6to4 included). Host names are checked with the same pinned DNS lookup ashttp_requestandweb_fetch: the host is resolved once per connection, every address is checked, and the socket connects to the address that was checked, so a DNS-rebinding name (public to the check,127.0.0.1to the connection) never reaches a private address. IP literals in any formURLaccepts (2130706433,0x7f000001,0177.0.0.1,[::ffff:127.0.0.1]) andlocalhost/*.localhostnames are refused when the tools are made, as aConfigurationError. The model cannot move a request to another host: path values are percent-encoded into their segment, server URL variables take only the document's defaults, and redirects stay on the base origin. List your own internal hosts inallowPrivate(allowPrivate: ['localhost']for a local API). A refused request is a tool error that names the host, never the addresses it resolved to.'refuse'cannot be combined withfetch: a customfetchresolves names itself, so the check could not pin the address it connects to, and making the tools throws instead of checking only half. With your ownfetch(or a proxy), check destinations there.'refuse'needs Node.js (undiciandnode:dns); on a runtime without them, making the tools throws aConfigurationErrorrather than send unchecked requests. Cloudflare Worker builds do not offeropenApiTools.
An API with hundreds of operations is hundreds of tools. Give the agent the ones
it needs with include or exclude, or with a predicate on op.tags:
import { openApiTools } from '@lousho/build-ai-agent';
declare const spec: object;
const tools = await openApiTools(spec, { include: (op) => op.tags.includes('orders') });- OpenAPI 3.0 and 3.1 only. A Swagger 2.0 document throws a
ConfigurationErrorthat asks you to convert it first. - Request bodies must be JSON (
application/jsonor+json). An operation with only a multipart, form or XML body is left out; naming it inincludethrows. - Only the first
serversentry is used, with variables at their defaults. Path-level and operation-levelserversare ignored. - Every
$refmust be local (#/components/...); a reference to another file or URL throws. Bundle the document first. - No OAuth flows.
bearerTokenandheaderscover static and per-request credentials. - Response bodies are read as text, and responses are not streamed.