Classic painting used as the article cover
← Back to blog

CONNECTORS

Building a Connector: From OpenAPI Spec to Governed Agent Tools

Generating tools from an OpenAPI document is the easy 20%. This is the other 80%: operation selection, lossy schema mapping, per-user auth, pagination, backoff, idempotency, and write safety.

Paulina XuAug 21, 202619 min
ConnectorsEngineeringProtocols

TL;DR

Generating tools from an OpenAPI document is the easy twenty percent of building a connector. Point a generator at a 400-endpoint internal spec and it happily emits 400 tools, which is already a failure: tool selection accuracy craters past 30-50 tools, and four hundred candidates don't fit a context window regardless.

The other eighty percent is what the specification never describes: which operations an agent should actually have, whose credential a call runs under, and what happens when a page boundary or a duplicate write reaches a model that can't infer either.

We think the mistake is architectural, not tactical. Teams treat spec-to-tools as the whole connector, when generation is a build-time concern and governance (authorization, credential resolution, retries, idempotency, write policy) is a call-time one that has to run on every single call.

Build it that way from the start. A connector that mixes the two layers, deciding upfront what a caller may do, ends up with four hundred tools and no boundary around any of them.

Overview

There is a demo that always works. Point a generator at an OpenAPI document, watch it emit a tool per operation, hand the tools to a model, ask a question in English. The model picks an operation, fills in parameters, and answers. It takes four minutes and it is genuinely impressive.

Then you try it against a real internal API. Take a warehouse management system with a 400-endpoint specification accumulated over nine years, and every part of the demo breaks at once. Four hundred tools do not fit in a context window, and would not be selected correctly if they did. Half the operationIds are absent or unreadable. The list endpoints paginate three different ways. The write endpoints are not idempotent. The API rate-limits at a tier the agent exhausts in six steps. And the whole thing runs behind a service credential giving every user of the agent the union of every user's access.

Every one of those failures traces back to the same four minutes of demo assuming it was the whole job. Selecting which operations survive, whose credential a call carries, what a page boundary looks like to a model that can't infer one: none of that is described anywhere in the specification, and none of it is optional once real users are on the other end. The general principles of tool schema design are treated separately in "Designing Tools Agents Can Actually Use," and this post assumes them.

The current OpenAPI release is 3.2.0, dated 19 September 2025, and the OpenAPI Initiative's own last word on the long-mooted 4.0 effort is that "the timeline for Moonwalk reaching a 4.0.0 release remains open-ended." The substrate, in other words, is stable enough to build against. The maintainers of one of the better OpenAPI-to-tool generators are candid about the ceiling of their own feature, too: "LLMs achieve significantly better performance with well-designed and curated MCP servers than with auto-converted OpenAPI servers," and generation should be used "for bootstrapping and prototyping, not for mirroring your API." We agree.

A connector is not a translation layer. It is a curation layer with a policy boundary in it. The specification tells you what the API can do. It tells you almost nothing about what an agent should be able to do, on whose behalf, or what happens when it goes wrong.

Selecting Operations

The first decision is the one generators make for you by default, and it is the wrong one: the typical default converts every endpoint into a tool. For a 400-endpoint spec that produces 400 tools, which fails on both axes that matter.

It fails on tokens. Anthropic measured a five-server setup at 58 tools consuming roughly 55,000 tokens of definitions before any work begins, and a 400-tool connector is worse than that on its own. It fails on selection too: Anthropic states plainly that "Claude's ability to pick the right tool degrades once you exceed 30–50 available tools." Four hundred candidates isn't a slightly harder version of thirty. It's a different problem.

The fix isn't a smarter default. It's a set of choices someone has to make deliberately, and they're not equally valuable.

Exclude by default, include deliberately. Invert the generator's polarity: start from the tasks the agent must perform, enumerate the operations they require, generate only those. It's unglamorous, and we'd argue it's the highest-leverage thing in the whole pipeline. A triage agent over a warehouse system needs maybe twelve operations out of four hundred.

Filter on the spec's own taxonomy, and collapse what's genuinely redundant. Tags are the usual axis, and most generators support tag-based include and exclude lists; OpenAPI 3.2.0 improved this by restructuring the Tag Object to add summary, a parent field for nesting, and a kind for classification, so a well-tagged spec expresses a hierarchy a connector can walk. Collapsing helps too, but only within a consequence class: merging three read endpoints into one search tool is pure gain, while merging a delete into an update loses the tool name an approval policy attaches to. Neither strategy does much on its own against a 400-endpoint spec.

Defer the rest, which is the one worth taking seriously. Progressive disclosure keeps surviving-but-not-always-relevant operations out of context until needed. Tools marked defer_loading: true stay known to the request but stay out of the model's context until a search surfaces them, then get appended as references so the prompt cache survives. The reported effect runs both ways: over 85% reduction in definition tokens, and accuracy improvements on Anthropic's MCP evaluations from 49% to 74% for one model and 79.5% to 88.1% for a newer one. That's a bigger swing than most model upgrades produce, and the search index covers "tool names, descriptions, argument names, and argument descriptions," which is a real argument for propagating the specification's parameter description fields faithfully instead of dropping them.

Naming is the other half, and it's where specs fight back. operationId is optional, and where present it "MUST be unique among all operations described in the API." None of that requires it to be readable. Anthropic's tool name must match ^[a-zA-Z0-9_-]{1,64}$, a hard ceiling real-world operationIds routinely exceed. Generators handle this mechanically: one truncates to 56 characters and de-duplicates with numeric suffixes. The result is often unusable. Maintain an explicit name map for anything the agent calls often.

tool_use

OpenAPI 3.x document

Parse and resolve refs

SELECT: allowlist,
tags, collapse

Name and describe

Map schemas
lossy

Tool definitions
some deferred

Model

BOUNDARY:
authorize + inject credential

Paginate, backoff,
idempotency, shape

Upstream API

parse/resolve --> SELECT (allowlist, tags, collapse) --> name+describe --> map schemas (lossy) --> tool definitions (some deferred) --> model model --tool_use--> BOUNDARY [authorize + inject per-user credential] --> paginate / backoff / idempotency / response shaping --> upstream API Generation is build-time. The boundary is call-time. They are different layers. -->

Figure 1 — Everything left of the model is build-time curation; everything right of it is call-time policy. Conflating the two is how connectors end up ungovernable.

Lossy Schema Mapping

Mapping an OpenAPI parameter schema to a tool input schema looks like a format conversion. It is a lossy one, and knowing where it loses information tells you what your connector code must compensate for.

There are two regimes. By default, input_schema is injected into the model's context as JSON Schema text, and nothing enforces it. A minimum: 1 is a suggestion, not a rule. In strict mode the schema is compiled into a grammar that constrains token sampling, which makes conformance a guarantee instead. Strict mode is what you want for a generated connector, because generated schemas tend to be wide and models fill wide schemas creatively. But it supports a restricted keyword subset, and that subset is where the losses live.

OpenAPI constructFate in a strict tool schemaWhat the connector must do
oneOfNot supported (anyOf is)Rewrite to anyOf, which loosens "exactly one" to "at least one," and validate exclusivity yourself in the handler
discriminatorNo equivalentFlatten to separate tools, or validate the tag server-side
minimum / maximum / multipleOfNot supportedRange-check in the handler; return an instructive error
minLength / maxLengthNot supported (pattern is, over a restricted regex subset)Validate lengths in the handler
maxItems, uniqueItemsNot supported (minItems only for 0 and 1)Validate in the handler
Recursive $refNot supportedFlatten to fixed depth, or expose the nested resource as its own tool
External $refNot supportedResolve and inline at build time
allOf containing $refNot supportedMerge composed schemas at build time
additionalProperties (defaults to true in 3.0.x)Must be falseInject closure the source spec never asserted

The most instructive row is the numeric one. Anthropic's SDKs strip unsupported constraints from the wire schema and validate them locally on the response instead. A minimum: 1 on a page_size doesn't constrain generation at all: it becomes a post-hoc rejection, which is a retry loop wearing the costume of a type system. Any constraint you actually care about has to be enforced by your handler.

Parameters using content instead of schema have no flat JSON Schema to lift. The specification requires a Parameter Object "MUST include either a content field or a schema field, but not both," so a media-typed parameter needs a hand-written tool input; OpenAPI 3.2.0's new querystring location is exactly this case. Polymorphic responses are worse, though here the specification at least admits it: after describing the legal combinations of discriminator with oneOf, anyOf, and allOf, it states that "the behavior of any configuration of oneOf, anyOf, allOf and discriminator that is not described above is undefined." A connector can't resolve an undefined case correctly. Pick a shape, normalize to it, and document what you dropped.

Then there's the version question. OpenAPI 3.1 and 3.2 align the Schema Object with JSON Schema Draft 2020-12; 3.0.x uses an "extended subset" of the older Draft Wright-00, where type cannot be an array and nullability needs a separate nullable keyword. If your connector ingests both, which in an enterprise it usually will, normalize 3.0 to 3.1 semantics in a discrete pass before generation: nullable: true becomes a "null" member of a type array, a boolean exclusiveMinimum becomes a numeric one, and a schema-level example becomes examples.

python
def operation_to_tool(op: dict, path: str, method: str) -> dict:
    """One OpenAPI operation -> one tool definition. Build-time."""
    props, required, checks = {}, [], []

    for p in op.get("parameters", []):
        if "content" in p:                      # no flat schema available
            raise Unmappable(f"{path}: content-typed parameter {p['name']!r}")
        schema = normalize_30_to_31(p["schema"])
        props[p["name"]] = {
            "type": json_type(schema),
            # The spec's own prose is the best description we will get.
            "description": p.get("description") or f"{p['name']} ({p['in']} parameter)",
            **({"enum": schema["enum"]} if "enum" in schema else {}),
        }
        if p.get("required") or p["in"] == "path":
            required.append(p["name"])
        checks.extend(constraint_checks(p["name"], schema))   # min/max/pattern/...

    return {
        "name": tool_name(op, path, method),      # <= 64 chars, mapped, unique
        "description": build_description(op),     # summary + description + returns
        "input_schema": {
            "type": "object",
            "properties": props,
            "required": required,
            "additionalProperties": False,        # required by strict mode
        },
        "strict": True,
        "_binding": {"method": method, "path": path, "checks": checks},
    }

The _binding field is what a naive generator omits. Constraints the tool schema could not express have to travel with the tool to its handler, because that is now the only place they can be enforced.

Auth and Credentials

OpenAPI describes five security scheme types: apiKey, http, mutualTLS (added in 3.1, absent from 3.0.x entirely), oauth2, and openIdConnect. All of that tells you how to authenticate. None of it tells you whose credential to use, and that's the decision that actually determines whether the connector is governable.

We think the distinction is stark. A service credential is one identity for all callers: simple to configure, and it makes the agent's effective reach the union of everything that credential can touch, for every user, forever. The downstream system's logs then attribute every action to the agent, so at the only genuinely authoritative layer you cannot answer "on whose behalf." A per-user credential, resolved at call time for the human the run acts for, makes the upstream API's access controls do work for you rather than around you, and makes its audit trail agree with yours.

OWASP is direct on both halves. Under Excessive Agency, its "execute extensions in user's context" control asks you to "track user authorization and security scope to ensure actions taken on behalf of a user are executed on downstream systems in the context of that specific user, and with the minimum privileges necessary." OAuth with the minimum scope required is the example it gives. We think its complete-mediation control is the best one-line statement of the whole connector thesis: "implement authorization in downstream systems rather than relying on an LLM to decide if an action is allowed."

For OAuth connectors the standards picture in mid-2026 is worth stating accurately, because it moves fast. OAuth 2.1 is still an Internet-Draft (revision 15, dated 2 March 2026), not an RFC. Its consequential changes for a connector author: PKCE required by default rather than optional, the implicit grant removed, redirect URIs matched by exact string. Resource Indicators (RFC 8707) is what keeps a token from being useful in the wrong place. The resource parameter must be an absolute URI without a fragment, and the authorization server "SHOULD audience-restrict issued access tokens to the resource(s) indicated." For the delegation semantics, Token Exchange (RFC 8693) supplies the vocabulary: the act claim (§4.1) records that delegation occurred, and may_act (§4.4) asserts who may act for whom.

If your connector speaks MCP, the current specification revision has tightened this considerably. An MCP server "acts as an OAuth 2.1 resource server" and MUST implement Protected Resource Metadata (RFC 9728); the matching MUST for Resource Indicators (RFC 8707) falls on the client, which has to send resource on both the authorization request and the token request whether or not the authorization server supports it. The anti-passthrough rules are the ones to internalize, because they outlaw the shortcut most connectors take: servers "MUST validate that access tokens were issued specifically for them as the intended audience," and "MUST NOT accept or transit any other tokens." Two details are easy to get wrong: Dynamic Client Registration (RFC 7591) is now deprecated in favour of Client ID Metadata Documents, and stdio transports are told not to follow this flow, retrieving credentials from the environment instead.

Whatever the protocol, the implementation shape is the same and the ordering is what matters: authorize, then resolve, then call.

python
def call(tool: dict, args: dict, ctx: CallContext) -> dict:
    authorize(tool["name"], ctx)                     # agent scopes ∩ user scopes
    validate(args, tool["_binding"]["checks"])       # constraints the schema lost

    token = vault.fetch(user_id=ctx.user_id, provider=tool["_provider"])
    with httpx.Client(
        base_url=BASE_URL,
        headers={"Authorization": f"Bearer {token}",
                 "X-Request-Id": ctx.request_id},
        timeout=httpx.Timeout(15.0, connect=3.0),
    ) as api:
        return execute(api, tool, args, ctx)

The credential never enters the model's context, and it is resolved after the authorization decision rather than before it. Both properties are cheap here and impossible to add later.

Pagination

Pagination deserves a section because the specification gives you nothing. Across the entire OpenAPI 3.2.0 document the word "pagination" appears twice, both inside non-normative examples. There is no Pagination Object, no keyword, no normative guidance. Every API invents its own, and your connector is where they get normalized.

Page-token, opaque. Google's AIP-158 codifies the strictest and most agent-friendly rules: page_size and page_token must not be required, and tokens "must be opaque (but URL-safe) strings, and must not be user-parseable." The rule that matters most in an agent loop, we think, is the empty-token one: "if the end of the collection has been reached, the next_page_token field must be empty. This is the only way to communicate 'end-of-collection' to users." Naive implementations break on the rule sitting right next to it: an API "may return fewer results than the number requested (including zero results), even if not at the end of the collection." A short page doesn't mean you're done.

Cursor on domain identifiers. Stripe takes object IDs as cursors via mutually exclusive starting_after and ending_before, with limit defaulting to 10 and capped at 100, and a has_more boolean whose false "comprises the end of the list." Leakier than opaque tokens, considerably easier to debug.

Link headers. GitHub returns a link header with next, prev, first, and last relations, and recommends following those URLs rather than constructing them; RFC 8288 defines the header syntax. GitHub is also the best illustration of why normalization is necessary: its own documentation concedes that "the query parameters in the link URLs may differ between endpoints," with different paginated endpoints using page, before/after, or since. One API, three schemes.

A connector should expose one shape regardless of what sits underneath, and bound the work rather than letting a model paginate a warehouse.

python
MAX_PAGES = 5          # bound the agent's exposure to a large collection
MAX_ROWS  = 200

def paginated_call(api, tool, args, ctx) -> dict:
    rows, cursor, pages, truncated = [], args.get("cursor"), 0, False

    while pages < MAX_PAGES and len(rows) < MAX_ROWS:
        page = request_with_retry(api, tool, {**args, "cursor": cursor}, ctx)
        rows.extend(shape(r) for r in page.items)
        pages += 1
        cursor = page.next_cursor
        if cursor is None:                    # the ONLY end-of-collection signal
            break
        # NB: a short page is not the end. Never break on len(page.items) == 0.
    else:
        truncated = cursor is not None

    return {
        "results": rows[:MAX_ROWS],
        "returned": len(rows[:MAX_ROWS]),
        "next_cursor": cursor,
        "truncated": truncated,
        "note": None if not truncated else
                "Stopped at the connector's page limit. Narrow the filters "
                "rather than continuing to paginate.",
    }

The function above makes three things non-negotiable: normalize whichever upstream scheme into one next_cursor, never treat an empty page as the end (per AIP-158), and tell the model when it truncated so it knows to narrow rather than keep going. Skip that last part and a model handed a cursor with no guidance will paginate exhaustively. Forty tool calls over warehouse rows is how one question eats a whole context window.

Response shaping belongs here too. The shape() call isn't cosmetic: a record with 180 fields becomes six, because tokens saved are tokens the agent spends reasoning. Prefer semantically meaningful identifiers as well. Anthropic's guidance notes that resolving opaque ones into interpretable ones measurably reduces hallucination in retrieval tasks.

Rate Limits and Backoff

An agent is an unusually bad rate-limit citizen: it issues bursts, retries eagerly, and every wasted retry consumes a step from a budget that's bounded for other reasons entirely. Backoff inside a connector has two jobs that pull against each other. Be polite to the upstream API. Don't silently burn the agent's step budget waiting.

The standards picture needs correcting, because the commonly cited version is out of date. 429 Too Many Requests is defined in RFC 6585 §4, not RFC 9110. RFC 6585 has not been obsoleted, and RFC 9110 does not define 429 at all. Retry-After is RFC 9110 §10.2.3, and it carries a parsing hazard: its value "can be either an HTTP-date or a number of seconds," so a connector must handle both.

Standardization of rate-limit advertisement is live but unfinished, and the field names have changed from the ones most write-ups still quote. The current draft is revision 11, dated 23 May 2026, and defines exactly two structured fields: RateLimit-Policy, advertising quota policies with parameters q (quota), w (window in seconds), and pk (partition key); and RateLimit, reporting availability with r (remaining) and t (time to reset). The older RateLimit-Limit / -Remaining / -Reset triple survives only as an appendix survey of X--prefixed de-facto headers. Policies are named, so one response can advertise several windows:

http
HTTP/1.1 429 Too Many Requests
RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400
RateLimit: "burst";r=0;t=42
Retry-After: 42
Content-Type: application/problem+json

The precedence rule is explicit: when both are present, "the Retry-After field MUST take precedence and the effective window MAY be ignored."

For the waiting itself, the canonical reference remains the AWS analysis of exponential backoff and jitter, whose Full Jitter formula is sleep = random(0, min(cap, base * 2 ^ attempt)). Jitter isn't a refinement. Skip it and un-jittered backoff synchronizes retries across clients into exactly the thundering herd the backoff was meant to prevent.

python
import random, time

def request_with_retry(api, tool, args, ctx, attempts: int = 4):
    base, cap = 0.25, 8.0
    for attempt in range(attempts):
        r = api.request(tool["_binding"]["method"],
                        render_path(tool, args), **build_request(tool, args))
        if r.status_code not in (429, 503) or attempt == attempts - 1:
            return r

        server_hint = parse_retry_after(r.headers.get("Retry-After"))  # date or secs
        # Retry-After wins over the advertised RateLimit window (draft-11).
        delay = server_hint if server_hint is not None else \
                random.uniform(0, min(cap, base * 2 ** attempt))       # full jitter

        if ctx.deadline_exceeded_after(delay):
            # Don't hold the agent's step hostage; return an actionable error.
            r.raise_for_status_as_retryable(delay)
        audit(ctx, tool, outcome="throttled", wait_s=round(delay, 2))
        time.sleep(delay)
    return r

The deadline check is the connector-specific part. A twelve-second sleep inside a tool call is invisible to the model, indistinguishable from a slow API, and unrecoverable: the agent simply waits. Bounding retries by a wall-clock budget, and converting an over-budget throttle into an explicit "the system is busy, do not retry, report it" result, is what separates a slow agent from a stuck one.

The Error Taxonomy

An HTTP status code is a fact. What the agent should do next is a decision, and the connector has to make it because the model cannot. Four classes are enough, and we don't think you need a fifth.

RFC 9457 is the right wire format to prefer where the upstream offers it. It obsoletes RFC 7807, defines the application/problem+json media type, and carries five members: type (a URI identifying the problem type, defaulting to about:blank), title, status, detail, and instance. The discipline for a connector is that type is the machine-classifiable key and detail is prose. Branch your retry policy on type; put detail in the string you hand the model. An agent that branches on detail breaks the first time someone improves an error message.

python
def classify(r, tool, args) -> tuple[str, str]:
    """-> (class, message the model can act on). class in:
       retryable | fatal | needs_different_input | needs_human"""
    problem = parse_problem_json(r)          # RFC 9457; may be None
    kind = problem.type if problem else None

    if r.status_code in (429, 503) or kind == "quota-exceeded":
        return ("retryable",
                "The upstream system is throttling or unavailable. Do not retry "
                "in this step; report that it is busy.")
    if r.status_code in (401, 403) or kind == "insufficient-scope":
        return ("needs_human",
                "Access was refused for this user. Do not retry. Report that a "
                "permission grant is required.")
    if r.status_code in (400, 422):
        return ("needs_different_input",
                f"The arguments were rejected: {detail_of(problem, r)} "
                f"Correct them and retry once.")
    if r.status_code == 404:
        return ("needs_different_input",
                "No such record. Search for the correct identifier first.")
    if r.status_code == 409:
        return ("fatal",
                "Conflict: the record changed, or this request is already in "
                "progress. Re-read the record before acting again.")
    if r.status_code >= 500:
        return ("retryable",
                "The upstream system is failing. Do not retry; stop and report.")
    return ("fatal", "The call was refused. Do not retry.")

401 and 403 are needs_human rather than fatal, because a permission grant is something a person can act on, and the model should say so instead of declaring defeat. 429 is trickier: it's retryable at the connector level, but the message tells the model not to retry. That's not a contradiction. The retry already happened inside request_with_retry, and by the time the model sees this string, the budget is spent.

yes

no

yes

yes

no

no

yes

no

yes

no

Upstream response

2xx?

Shape, paginate, return

429 / 503 / 5xx?

Retry budget
and deadline left?

Backoff with full jitter,
honour Retry-After

retryable: report busy,
do not retry

401 / 403?

needs_human:
permission grant required

400 / 404 / 422?

needs_different_input:
correct and retry once

fatal: do not retry

shape/paginate/return -- no --> 429/503/5xx? -- yes --> budget left? -- yes --> jittered backoff (Retry-After wins) -> retry -- no --> RETRYABLE (report busy) -- no --> 401/403? -- yes --> NEEDS_HUMAN (grant required) -- no --> 400/404/422? -- yes --> NEEDS_DIFFERENT_INPUT -- no --> FATAL -->

Figure 2 — The four terminal classes correspond to four different things the agent should do, which is the only reason to have four of them.

Write Safety

Writes are where a connector stops being an integration and becomes a governance surface.

Start from the normative rule, because it is unambiguous and routinely ignored. RFC 9110 §9.2.2 establishes that "PUT, DELETE, and safe request methods are idempotent," then states the constraint governing every retry wrapper you will write: "a client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent… or some means to detect that the original request was never applied." A generated connector applying one retry policy uniformly across every operation violates this on every POST.

The "means to know" is an idempotency key, and the honest status of that mechanism is worth stating: it is a de facto standard with no standard behind it. The IETF draft specifying the Idempotency-Key header reached revision 7 on 15 October 2025 and expired in April 2026 without becoming an RFC. Build against your provider's contract. That said, the draft's status-code semantics are the clearest articulation available and most vendors approximate them: reusing a key with a different payload should return 422, retrying while the original is in flight 409, and omitting a required key 400. Stripe's implementation is representative: keys up to 255 characters, accepted on all POST requests, pruned after roughly 24 hours, erroring when parameters differ, and replaying the saved response including 500s.

Derive the key rather than accepting one from the model. A model that regenerates it on retry defeats the mechanism entirely. Gate by consequence, not by verb, since plenty of destructive operations are POSTs. And support dry runs where the upstream does; where it doesn't, a read-then-diff before the write is a serviceable substitute, and it doubles as a useful approval payload.

python
import hashlib, json

WRITE_POLICY = {                          # assigned at selection time, not inferred
    "wms_update_inventory_count": "approve",
    "wms_create_shipment":        "auto",
    "wms_cancel_shipment":        "approve",
    "wms_delete_location":        "refuse",
}

def execute_write(api, tool, args, ctx) -> dict:
    policy = WRITE_POLICY.get(tool["name"], "approve")     # default to caution
    if policy == "refuse":
        raise Denied(f"{tool['name']} is not available to agents.")
    if policy == "approve" and not ctx.has_approval(tool["name"], args):
        return {"status": "awaiting_approval",
                "preview": dry_run(api, tool, args),
                "message": "Human approval required. Do not retry; report pending."}

    key = hashlib.sha256(json.dumps(
        [ctx.agent_id, ctx.user_id, tool["name"], identifying(args)],
        sort_keys=True).encode()).hexdigest()

    r = api.request(tool["_binding"]["method"], render_path(tool, args),
                    json=build_body(tool, args),
                    headers={"Idempotency-Key": key})
    audit(ctx, tool, outcome="write", idempotency_key=key,
          status=r.status_code)                      # logged before returning
    return interpret_write(r, key)

Defaulting unknown operations to approve rather than auto is the decision that matters most in that block. A connector generated from a 400-endpoint spec will always have operations nobody classified, and the only question is whether they fail safe or fail open.

The connector, not the model, decides what is safe. A tool the model can call is a tool the model will eventually call with wrong arguments. Write safety has to be a property of the boundary, because it cannot be a property of the caller.

Without a Spec

Plenty of what an enterprise agent needs to reach has no OpenAPI document at all.

Databases. A direct SQL tool is the most tempting and most dangerous connector to build, and the failure mode is documented rather than hypothetical: peer-reviewed work at ICSE 2025 coined "P₂SQL injection" for prompt-to-SQL attacks and found that LLM-integrated applications built on LangChain, its case study, are "highly susceptible" across seven state-of-the-art models. A read-only posture is the sane default, and it needs layering because no single control suffices. Postgres gives you default_transaction_read_only, and SET TRANSACTION READ ONLY blocks INSERT, UPDATE, DELETE, MERGE, COPY FROM, all CREATE/ALTER/DROP, plus COMMENT, GRANT, REVOKE, and TRUNCATE. The documentation itself concedes the caveat that matters, and it's the whole argument for defense in depth: "this is a high-level notion of read-only that does not prevent all writes to disk."

So a read-only transaction is a guardrail, not a boundary. The boundary is a least-privilege role holding no write grants at all. Then add the timeouts, which matter more for agents than for applications because an agent will happily open a transaction and go away to think: statement_timeout (disabled by default, applied per statement since Postgres 13), transaction_timeout, and idle_in_transaction_session_timeout to stop a deliberating agent from holding locks. Finish with a LIMIT the connector injects rather than trusts. OWASP's Excessive Agency guidance lands in the same place: minimize extension functionality, avoid open-ended extensions, and enforce the rest "by applying appropriate database permissions for the identity that the LLM extension uses to connect to the database."

Internal services behind a network boundary. Reachability without inbound exposure is one thing: it's what an outbound tunnel provides. A daemon inside your network dials out, so you "configure your firewall to allow only these outbound connections and block all inbound traffic," in Cloudflare's description of its tunnel connector. Per-identity authorization is a different thing entirely, solved at the application layer rather than the perimeter; Google's Identity-Aware Proxy frames itself as "an application-level access control model instead of relying on network-level firewalls." Teams reach for the tunnel and stop there, which conflates two different problems that happen to share a network diagram. A governed connector needs both, and a tunnel with no authorization in front of it has just relocated the perimeter rather than built one.

Legacy systems. SOAP endpoints, fixed-width file drops, screen-scraped terminals. Build the narrow, purpose-shaped tool the agent actually needs rather than a general-purpose adapter. The legacy interface is exactly where wide capability is least safe and least necessary. If the only reliable path is a nightly export, expose it as a queryable read-only store and say so in the tool description: the data is stale, and the agent should know it.

We take the same approach inside Agentic Fabriq. OpenAPI import generates tools from a specification automatically, so the mechanical part isn't work you repeat; custom database tools are Postgres-only with SELECT-only guardrails, for the reasons above; and the Private Network Connector uses an outbound-only tunnel so an internal API can be reached without opening an inbound path. Underneath all three, the effective tool list is the intersection of the user's and the agent's scopes, and both identities are carried on every request.

Conclusion

The generated part of a connector is real work and it is not the hard part. The hard part is everything the specification does not describe: which of four hundred operations an agent should have at all, what happens to the constraints your tool schema cannot express, whose credential the call carries, how a page boundary and a rate limit and a duplicate write are represented to a model that cannot infer any of them, and which operations are simply not available to software. None of that is exotic engineering, and it looks unnecessary only because the four-minute demo exercises a read-only endpoint against a small dataset with a service credential in a system nobody depends on. All four of those conditions are false in production.

The organizing principle decides the architecture: generation is a build-time concern and governance is a call-time concern. Tool definitions, names, descriptions, and schema mappings are produced once and reviewed. Authorization, credential resolution, pagination bounds, retry budgets, idempotency keys, and write policy are evaluated on every call. A connector that mixes those layers, deciding at generation time what a caller is allowed to do, has produced four hundred tools and no boundary.

Build the connector so the interesting decisions happen at call time. Which operations exist is a curation problem you solve once. Whether this call, for this user, on behalf of this person, right now, is allowed is a question that has to be asked every time, and the connector is the only place it can be asked.

Sources