Build an AI Assistant That Generates Working cURL Commands from API Documentation

The cURL command was flawless. Correct method, correct URL, correct headers, correct JSON body. It failed with a 422 because the model put status in the body when the API expected it as a query parameter, and used "active" when the only accepted values were "pending" and "draft".

Everything about the command looked professional. That's what makes this problem hard. A broken cURL command doesn't look broken. It looks like a request that should work, generated by a system that read the documentation and understood it. The failure is buried in a parameter location and an enum value that the retrieval step never surfaced.

Building an assistant that generates working cURL commands from API documentation is not a prompt engineering problem. It's a pipeline problem with three separable stages: retrieving the right endpoint with complete context, constraining the generated request to the contract, and verifying the command before it reaches a terminal. Most implementations get the first stage roughly right and treat the other two as afterthoughts. That's why so many generated cURL commands fail on the first try.

The research on this is blunt. In a benchmark of 24 state-of-the-art open-source models on web API integration tasks, none solved more than 40% of the tasks without help . The errors weren't exotic. They were hallucinated endpoints, wrong argument usage, and parameters placed in the wrong location. A pipeline that addresses those three failure modes directly is the difference between a demo and a tool.

📋 Table of Contents

Why Naive RAG Produces cURL Commands That Don't Work

An OpenAPI document is not a collection of independent facts. It's a graph of context-dependent definitions. A parameter description is meaningless without the endpoint it belongs to. A security scheme defined at the document root applies to operations that reference it, but a naive chunker leaves the scheme behind. A $ref to an enum resolves to values the model must know to generate a valid request, but a chunk that contains only the pointer gives the model a string that looks like a schema and behaves like a dead end.

The academic work on this is specific. When researchers applied RAG to endpoint discovery, they found that endpoint-based chunking outperforms naive chunking for OpenAPI preprocessing . They also found that API specifications are “highly context-dependent—retrieving smaller chunks out of context would be detrimental” . The documentation of a parameter is useless if it's unclear which endpoint it belongs to.

There's a second failure that has nothing to do with retrieval. Even when the correct endpoint is retrieved, the model can generate a request that passes JSON schema validation but violates the API's semantic contract. A customer_id sent as an integer instead of a UUID. A required query parameter placed in the body. An enum value that exists in a different schema but not this one. Schema validation checks shape. It doesn't check meaning.

And there's a third failure that most pipelines ignore until it becomes an incident: credential exposure. A cURL command with an API key in a header is a secret written into shell history, process listings, and logs. Tokens passed via -H "Authorization: Bearer $TOKEN" are visible in ps, top, and /proc/*/cmdline . An assistant that generates working commands but leaks credentials is not a tool you can deploy.

The pipeline in the rest of this article addresses all three, in order.

🔍 Why this matters: A cURL command that returns a 400 is annoying. A cURL command that succeeds while leaking a production token is an incident. Both are generation failures. Only one gets fixed by better prompting.

1. Chunk by Endpoint, Not by Token

Scenario: You index a spec with 120 operations. A user asks, “How do I cancel an order?” Naive retrieval returns a chunk containing the cancel path parameter and half a description, plus a fragment of an unrelated schema. The model sees enough to guess. It doesn't see the required reason field or the fact that cancellation is only allowed for orders in processing status.

Why token-based splitting fails: It has no concept of an operation. It splits through parameter lists, across $ref boundaries, and between an endpoint and its security requirements.

Solution: Parse the spec, resolve every internal reference, and make each operation a single self-contained chunk. The chunk includes the method, path, all parameters (path, query, header, cookie), the request body schema, response schemas, security requirements, and operation metadata.

Inlining $ref values before chunking is the step most pipelines skip. If a parameter references components.parameters.OrderId, resolve it into the chunk. If a schema references components.schemas.OrderStatus, resolve the enum into the chunk. If a security scheme is defined at the document root, copy the applicable scheme into every operation that uses it. This is exactly what the WAPIIBench preprocessing pipeline does: “inlining all references and copying API-wide security schemes and path-level parameters into each endpoint's parameter list” .

import jsonref

def build_endpoint_chunks(spec: dict) -> list[dict]:
    # Resolve every internal $ref before chunking.
    resolved = jsonref.replace_refs(spec, proxies=False)
    chunks = []

    for path, path_item in resolved.get("paths", {}).items():
        for method, operation in path_item.items():
            if method not in {"get", "post", "put", "patch", "delete", "head", "options"}:
                continue

            # Path-level parameters apply to every operation under the path.
            path_params = path_item.get("parameters", [])
            op_params = operation.get("parameters", [])
            merged = {
                (p["name"], p["in"]): p
                for p in path_params + op_params
            }

            chunks.append({
                "operation_id": operation.get("operationId", f"{method}_{path}"),
                "method": method.upper(),
                "path": path,
                "summary": operation.get("summary", ""),
                "parameters": list(merged.values()),
                "request_body": operation.get("requestBody", {}),
                "security": operation.get("security", resolved.get("security", [])),
            })

    return chunks

Why this works: The chunk boundary matches the smallest unit that can be invoked independently. Every parameter carries its location (in: query, in: path), so the model doesn't guess where an argument belongs. Security schemes travel with the operation that requires them. And because references are resolved, the model sees actual enum values instead of pointers.

One detail worth handling explicitly: path-level parameters. A parameter defined on /orders/{id} applies to every method under that path. If you only read operation-level parameters, you'll miss the path parameter and generate a request to /orders/{id} literally—which fails at the server.

💡 Practical note: Do not strip deprecated operations during chunking. Include them and mark them. If a user asks about a deprecated endpoint, the model should know it exists and should avoid recommending it—not be forced to hallucinate an alternative because the documentation was silently removed.

2. Embed Each Endpoint Multiple Times

Scenario: A user asks, “How do I filter orders by status?” The endpoint chunk for GET /orders contains a long description, ten query parameters, a large response schema, and error responses. A single embedding of the whole chunk is dominated by the response schema, which is irrelevant to the question. The endpoint ranks poorly, and retrieval returns a different operation that happens to mention “status” in its description.

Why single embeddings fail: An OpenAPI operation is not a coherent piece of prose. It's a bundle of different information types—path, description, parameters, request body, responses—each of which answers a different kind of question. One vector compresses all of them, and the largest section usually dominates.

Solution: Generate multiple embeddings per endpoint, each covering a different view. The WAPIIBench retriever stores up to five embeddings per chunk, created by including and excluding different parts of the documentation. One variant contains only the path and description; others add the parameter list or the response schema. At query time, the system retrieves across all variants and deduplicates by endpoint. This approach reports 75.7% top-1 accuracy and 95.2% top-5 accuracy across their task set .

def endpoint_views(chunk: dict) -> list[str]:
    base = f"{chunk['method']} {chunk['path']}\n{chunk['summary']}"

    views = [base]

    if chunk["parameters"]:
        views.append(
            base + "\nParameters:\n" + "\n".join(
                f"- {p['name']} ({p['in']}): {p.get('description', '')}"
                for p in chunk["parameters"]
            )
        )

    if chunk["request_body"]:
        views.append(base + "\nBody:\n" + _compact_schema(chunk["request_body"]))

    return views

Store each view with the same endpoint_id but a different view_type. At retrieval time, search all views, group by endpoint_id, and take the maximum similarity score per endpoint. This prevents a large, irrelevant section from dragging down the entire endpoint's relevance.

The WAPIIBench authors also tried reranking and found it could not improve retriever performance further on their setup . That's a useful negative result. Multi-view embedding is doing the heavy lifting; a cross-encoder on top added cost without adding recall. If you're building this, start with multi-view retrieval before adding a reranker, and measure whether the reranker earns its latency.

🧠 The important part: Multiple embeddings don't fix bad chunking. If the endpoint chunk is missing a required parameter or a security scheme, five views of an incomplete chunk just produce five incomplete retrievals. Inline first, then embed.

3. Serialize for the Model, Not for the Spec

Scenario: You retrieve the correct endpoint and paste the OpenAPI YAML fragment into the prompt. It's 180 lines of indentation, block scalars, and verbose constructs like name: order_id where the model only needs order_id. The prompt consumes 4,000 tokens for one endpoint. You can fit two endpoints at most, and the model still has to parse YAML to find the required fields.

Why YAML and JSON are token-inefficient here: OpenAPI is designed for machines and documentation generators, not for LLM consumption. YAML's indentation and key repetition are expensive. JSON's braces and quotes add tokens without adding meaning. The WAPIIBench authors explored both formats and found them token-inefficient due to “a lot of indentation and verbose constructs,” so they designed a custom format inspired by TypeScript type declarations .

Here's what that looks like in practice:

POST /orders
Summary: Create a new order
Auth: bearer

Body (application/json):
{
  customer_id: string (uuid, required)
  items: Array<{
    sku: string (required)
    quantity: integer (min: 1, required)
  }> (required)
  status?: "pending" | "draft"
}

Responses:
  201: Order { id: string (uuid), status: "pending" | "draft" }
  400: Error { code: string, message: string }

This format does several things at once. It uses ? for optional fields, | for enums, and <> for arrays—conventions models have seen extensively in training. It collapses required into a suffix rather than a separate JSON array. It flattens responses into one line each. The result is typically two to three times more compact than equivalent YAML, which directly increases the number of endpoints you can fit in the prompt.

For a production system, generate this format programmatically from the resolved endpoint chunk. A serializer that walks the schema and emits the compact syntax is more maintainable than prompting the model to “read YAML” and hoping it ignores the irrelevant parts.

⚠️ Gotcha: Compaction must not drop semantic constraints. If the original schema has minLength, pattern, minimum, or maxItems, those need to survive. Include them inline, even if it makes the output slightly less compact. Otherwise the validator becomes the only line of defense, and the model has no way to know what's legal.

4. Generate Request Data, Not Shell Strings

Scenario: The model is asked to “write a cURL command that creates an order.” It produces curl -X POST ... -d '{"customer_id": 123, "status": "active"}'. The JSON is syntactically valid. The customer_id is an integer instead of a UUID string. The status is not an allowed value. The command runs. The API returns a 422. The user copies the error into a ticket and loses trust in the tool.

Why free-text shell generation is the wrong default: When the model writes a shell command, it controls syntax, quoting, escaping, and semantics all at once. Each is a separate failure surface. A missing escape, a wrong parameter name, and an invalid enum value are all “cURL generation errors,” but they require completely different fixes.

Solution: When the goal is a working request, generate structured request data—method, path, query parameters, headers, body—and let the application render the cURL command. The model never emits a shell string. The renderer is deterministic, tested, and handles escaping correctly.

from pydantic import BaseModel, Field
from enum import Enum
from uuid import UUID

class OrderStatus(str, Enum):
    pending = "pending"
    draft = "draft"

class CreateOrderArgs(BaseModel):
    customer_id: UUID
    items: list[dict] = Field(min_length=1)
    status: OrderStatus = OrderStatus.pending

def render_curl(request: dict) -> str:
    parts = ["curl", "-X", request["method"]]

    for key, value in request.get("headers", {}).items():
        parts += ["-H", f"{key}: {value}"]

    if request.get("body"):
        parts += ["-d", json.dumps(request["body"], separators=(",", ":"))]

    parts.append(request["url"])
    return " ".join(shlex.quote(p) for p in parts)

Why this works: The model selects an operation and fills arguments against a schema. Pydantic validates types, required fields, and enum values before any cURL string exists. The application owns URL construction, header formatting, and body serialization. The only thing the model controls is the operation and the argument values—and both are validated.

This also fixes the arbitrary-URL problem. If the model can generate raw shell, it can be prompt-injected into sending data to an attacker-controlled host. If it can only fill a request schema, the set of reachable destinations is bounded by the endpoint catalog.

🚨 Production warning: Do not expose a generic curl_request tool that accepts arbitrary URLs and headers. Define tools at the operation level. A generic shell tool expands the attack surface to every reachable host and every header injection the runtime allows.

5. Constrained Decoding When Raw cURL Is Required

Scenario: You need the model to generate a runnable cURL command—for a documentation page, a test fixture, or a copy-paste snippet. You can't render it from structured data because the output is the cURL string. The model produces a command that uses -d for a GET request and omits a required Content-Type header. Post-hoc validation catches it. The retry costs tokens.

Why validation alone isn't enough: Validation catches errors after generation. Constrained decoding prevents invalid tokens from being generated in the first place, which reduces latency, cost, and retry loops.

Solution: Translate the OpenAPI operation into generation rules and enforce them during decoding. The WAPIIBench authors implemented this by automatically deriving constraints from OpenAPI specifications and representing them as regular expressions organized as generation rules with a start condition, stop condition, and body . The constraints are hierarchical: first the HTTP method, then the URL, then the parameters, then the value types. Capture groups prevent duplicate parameters, and conditionals guard against closing a parameter list before all required parameters are defined.

# Conceptual constraint for one operation.
# Real implementations compose these rules across the whole spec.
METHOD = r"curl\s+-X\s+POST"
URL = r"\s+'https://api\.example\.com/orders'"
HEADER = r"(\s+-H\s+'Authorization:\s*Bearer\s+[^']+')"
BODY = r"\s+--data\s+'\{\"customer_id\":\"[0-9a-f-]{36}\".*?\}'"

CONSTRAINT = re.compile(METHOD + URL + HEADER + BODY, re.DOTALL)

In practice, you generate these regexes from the spec at build time. The spec tells you which methods are valid for a path, which parameters are required, and which types each parameter accepts. The generator translates that into a grammar the decoding engine enforces. The WAPIIBench authors parsed 34 real-world OpenAPI specifications and validated their constraints against 125 positive and negative test cases, all of which passed .

The results are measurable. RAG reduces hallucinations and improves correctness when generating full API invocations—endpoint plus arguments. Constrained decoding reliably prevents illegal URLs, HTTP methods, and arguments and substantially improves correctness for both full and argument-only tasks . They complement each other: RAG fixes missing context, constrained decoding fixes invalid output.

ApproachWhat it fixesWhat it doesn't fixWhen to use
RAG onlyMissing endpoint contextInvalid arguments, illegal methodsYou control rendering and validate separately
Constrained decoding onlyInvalid syntax, illegal URLs, wrong methodsMissing context, wrong endpoint selectionYou know the endpoint and need a guaranteed-valid command
RAG + constrained decodingBothSemantic business rulesThe output must be both correct and executable
Post-hoc validation + retryMost errorsLatency and cost of retriesYou can't control decoding (hosted models)

💡 Practical note: There's a subtle RAG failure mode worth knowing. When the endpoint is already provided in the prompt and the model doesn't need to retrieve it, RAG can actually reduce correctness by encouraging the generation of unnecessary parameters . If you're doing argument-only completion, test with and without RAG. The retrieval step isn't free, and it isn't always helpful.

6. The Verification Ladder: Four Gates Before Execution

Scenario: The model emits a request. It passes Pydantic validation. The types are correct, the required fields are present, and the enum values are legal. But the customer_id belongs to a different tenant. Or the operation is a write that requires approval. Or the user is about to run a DELETE against production because the model inferred that intent from “clean up test orders.”

Why schema validation is not enough: Schema validation answers one question: does this argument object match the shape the tool declared? It doesn't answer whether the caller is authorized, whether the resource belongs to them, whether the side effect is safe, or whether the command should be shown with a dry-run warning first.

The production loop has four gates, in order:

Gate 1 — Schema validation. Reject unknown operations, missing required fields, wrong types, and values outside enums. Log every rejection with the raw arguments and the validation error. That log is the evaluation set for the next iteration.

Gate 2 — Semantic validation. Check constraints JSON Schema can't express. Does the customer exist? Is the order in a cancellable state? Does the quantity exceed the account limit? This gate is where API-specific business rules live.

Gate 3 — Authorization and policy. Bind the request to the authenticated user and tenant. The model proposes; the application decides. For destructive operations, require explicit confirmation. If the arguments change after confirmation, the confirmation is invalid.

Gate 4 — Dry run. Emit the resolved request without opening a network connection. The user sees the method, URL, headers (with credentials redacted), and body before anything executes. This is the “safe checkpoint” that turns a generated command into an inspectable proposal .

def verify_and_render(raw_args: dict, context: dict) -> dict:
    # Gate 1: shape and type.
    try:
        args = CreateOrderArgs(**raw_args)
    except ValidationError as e:
        return {"state": "rejected", "code": "INVALID_ARGUMENTS", "detail": e.errors()}

    # Gate 2: semantic.
    if not customer_exists(args.customer_id, context["tenant_id"]):
        return {"state": "rejected", "code": "UNKNOWN_CUSTOMER"}

    # Gate 3: authorization.
    if not can_create_order(context["user_id"], context["tenant_id"]):
        return {"state": "rejected", "code": "UNAUTHORIZED"}

    # Gate 4: dry run before execution.
    request = build_request(args, context)
    return {"state": "preview", "curl": render_curl(redact(request)), "request": request}

Why this works: Each gate answers a different question. Gate 1 asks whether the arguments are well-formed. Gate 2 asks whether they make sense for this API and this data. Gate 3 asks whether this caller is allowed. Gate 4 asks whether the user has seen what they're about to run. A cURL command that passes all four is not guaranteed to succeed—the API can still fail—but it is guaranteed to be a command the system had reason to believe was valid.

🚨 Production warning: Do not execute a generated cURL command directly with a shell. Parse it into a structured request and execute it with an HTTP client. Shell execution reintroduces command injection, quoting bugs, and argument smuggling. The only thing that should touch a shell is a human who explicitly copies the command.

7. Credential Isolation: The Model Should Never See the Token

Scenario: The generated cURL command includes -H "Authorization: Bearer sk_live_...". The user copies it into Slack to ask for help. The token is now in a chat log, a search index, and a backup. If the command was executed by the assistant, the token is also in the process list and shell history .

Why this is a pipeline design problem, not a user education problem: If credentials enter the model's context, they will eventually appear in output. Prompt instructions like “do not include the token” reduce the probability but don't make it zero. The only reliable fix is to keep credentials out of the model's context entirely.

Solution: Separate parameters from credentials at the spec level. OpenAPI already supports this: security schemes declare authentication separately from parameters, and operations reference schemes by name. The model should receive only parameters. The execution layer resolves credentials from a vault or environment and applies them at request time.

The WAPIIBench constraint generator explicitly handles authentication as part of the operation's security requirements, not as a model-supplied argument . Projects like Isolating Credentials describe the same architecture: “our execution pipeline knows exactly which inputs are parameters (validate against schema, pass to LLM) and which are credentials (resolve from vault, apply at request time)” .

def build_request(args: BaseModel, endpoint: dict, context: dict) -> dict:
    headers = {"Content-Type": "application/json"}

    # Credentials come from the vault, never from the model.
    scheme = endpoint["security"][0]
    if "bearerAuth" in scheme:
        headers["Authorization"] = f"Bearer {context['vault'].get('api_token')}"
    elif "apiKeyAuth" in scheme:
        headers[context["api_key_header"]] = context["vault"].get("api_key")

    return {
        "method": endpoint["method"],
        "url": join_url(endpoint["base_url"], endpoint["path"], args),
        "headers": headers,
        "body": args.model_dump(exclude_none=True) if endpoint["request_body"] else None,
    }

def redact(request: dict) -> dict:
    sensitive = {"Authorization", "X-API-Key", "Cookie"}
    return {
        **request,
        "headers": {
            k: ("<redacted>" if k in sensitive else v)
            for k, v in request["headers"].items()
        },
    }

The rendered cURL command for the user should contain a placeholder, not a secret: -H "Authorization: Bearer $API_TOKEN". If the user wants to run it, they export the token in their own shell. The assistant never touches the live credential.

This also solves the process-list leak. If the assistant executes the request internally, it should use an HTTP client with headers set in memory, not a curl subprocess with credentials in argv. The command-line exposure is a real vulnerability class, not a theoretical one .

🧠 The important part: A cURL command is a debugging artifact. It's meant to be copied, pasted, shared, and inspected. If it contains a live secret, every one of those actions is a leak. Render placeholders, resolve secrets at execution, and redact before display.

8. Normalize OpenAPI 3.0, 3.1, and 3.2 Before You Retrieve

Scenario: You build the pipeline against OpenAPI 3.0, then point it at a 3.1 spec. A nullable field is described as type: ["string", "null"]. Your serializer only understands type: string and nullable: true, so it emits nothing useful. The model sees a field with no type and generates a number where a string or null was expected.

Why version differences matter: OpenAPI 3.1 aligned the Schema Object with JSON Schema 2020-12, removing nullable in favor of type arrays. OpenAPI 3.2 adds first-class support for streaming media types like text/event-stream, the QUERY HTTP method, and hierarchical tags . A pipeline that treats all specs as 3.0 will silently drop or misinterpret these constructs.

Solution: Normalize before you serialize. Parse the spec version, run a version-aware normalizer that converts everything into a canonical internal representation, then serialize that for the model. Libraries like HttpLlm from @typia/utils do this: they upgrade any OpenAPI version—Swagger 2.0 through OpenAPI 3.2—to an emended 3.2 representation before converting each operation into an LLM function-calling schema .

def normalize_schema(schema: dict, version: str) -> dict:
    if isinstance(schema.get("type"), str):
        if version.startswith("3.0") and schema.get("nullable"):
            schema["type"] = [schema["type"], "null"]
            schema.pop("nullable", None)
        else:
            schema["type"] = [schema["type"]]

    for key in ("items", "properties"):
        if isinstance(schema.get(key), dict):
            schema[key] = {
                k: normalize_schema(v, version)
                for k, v in schema[key].items()
            } if key == "properties" else normalize_schema(schema[key], version)

    return schema

For 3.2 streaming responses, don't collapse the response into a single object. The itemSchema field describes the shape of each event. Include it explicitly and mark the endpoint as streaming, or the model will generate a request that expects a single JSON body and fail to parse the response.

For the QUERY method and additional operations, the tool catalog must include them. If your generator only recognizes get, post, put, patch, and delete, a QUERY operation is invisible to the model. It will either fail to answer or hallucinate a POST endpoint that doesn't exist.

⚠️ Gotcha: $ref resolution isn't identical across versions. OpenAPI 3.1 supports $dynamicRef and $dynamicAnchor, which many parsers still don't fully handle. If your resolver leaves a $dynamicRef unresolved, the model sees a pointer instead of a schema. Test against real 3.1 specs, not toy examples.

9. Evaluate Endpoint Retrieval and cURL Fidelity Separately

Scenario: The system answers most questions correctly in manual testing. Then a user asks for an operation that exists but is rarely used, and retrieval returns a similar but wrong endpoint. The model confidently generates a cURL command for the wrong operation. Because the endpoint exists and the schema is valid, the command executes. The user gets a plausible but incorrect result.

Why end-to-end evaluation hides failures: A single “correct/incorrect” metric conflates two systems. Retrieval can fail—the right endpoint isn't in the candidate set. Generation can fail—the right endpoint is retrieved, but the arguments are wrong. If you only measure the final answer, you can't tell which stage to fix.

Solution: Evaluate retrieval and generation against separate golden sets.

For retrieval, measure endpoint-level hit rate: for each task description, is the correct operation in the top-k retrieved endpoints? Report Hit@1 and Hit@5 separately. The WAPIIBench retriever reports 75.7% top-1 and 95.2% top-5 on its endpoint chunks . If your top-5 is below 90%, retrieval is the bottleneck; no amount of generation tuning will fix it.

For generation, measure argument fidelity across four dimensions: operation correctness, parameter presence, parameter values, and absence of extra parameters. The WAPIIBench evaluation pipeline checks the generated invocation against a ground-truth request configuration, which lets it attribute errors to specific arguments rather than marking the whole command wrong .

def evaluate_command(generated: dict, expected: dict) -> dict:
    return {
        "operation_match": generated["operation_id"] == expected["operation_id"],
        "missing_params": [
            p for p in expected["required"] if p not in generated["params"]
        ],
        "extra_params": [
            p for p in generated["params"] if p not in expected["allowed"]
        ],
        "wrong_values": {
            k: {"expected": v, "generated": generated["params"].get(k)}
            for k, v in expected["params"].items()
            if generated["params"].get(k) != v
        },
    }

Run this against a JSONL golden set on every pipeline change. A set of 30–50 tasks covering the operations users ask about most is enough to catch regressions that manual testing misses. If endpoint hit rate drops or argument fidelity regresses, block the merge.

💡 Practical note: Fifty well-annotated tasks beat five hundred unannotated ones. Annotate the endpoint, required parameters, allowed parameters, and expected values for each. The annotation work is tedious, but it's the only way to attribute failures to retrieval or generation.

What I'd Build, What I'd Skip, and When Not to Build This

Here's the architecture I'd choose, based on what holds up in production.

LayerChoiceWhy
Spec parsingA resolver that fully inlines $ref and supports 3.0, 3.1, and 3.2Unresolved references are the most common silent retrieval failure.
ChunkingOne chunk per endpoint, with path params and security schemes mergedEndpoint-based chunking outperforms naive splitting for OpenAPI RAG .
EmbeddingMultiple views per endpoint, deduplicated at retrievalSingle embeddings are dominated by large, irrelevant sections; multi-view improves top-k hit rate .
SerializationCompact TypeScript-like format, not YAML or JSONToken efficiency increases the number of endpoints that fit in context .
GenerationStructured request data with schema validation; constrained decoding only for raw cURL outputStructured data eliminates shell syntax errors; constrained decoding guarantees valid commands when a string is required .
VerificationFour gates: schema, semantic, authorization, dry runSchema validation alone misses business rules and authorization.
CredentialsResolved at render/execute time, never in model context; redacted in outputTokens in argv leak via process lists and shell history .
EvaluationSeparate endpoint hit rate and argument fidelity golden setsEnd-to-end accuracy hides which stage is failing.

What I'd skip

Embedding raw YAML. It's the source of truth, but it's the least token-efficient representation. Serialize a compact endpoint card instead.

A generic curl tool with no schema. If the model can generate arbitrary shell, you've replaced API documentation with a search engine and a loaded gun. Define operation-level tools with validated schemas.

Post-hoc validation as the only defense. Validation catches errors. It doesn't prevent them. Constrained decoding and structured generation prevent a class of errors that validation can only reject.

Trusting the model to redact credentials. It won't, reliably. Keep secrets out of the context entirely.

Building on an unmaintained API tool without checking its status. The ecosystem moves fast. Check the last release date and whether the project accepts fixes before you make it a production dependency.

When not to build this

If your API has one endpoint and five parameters, put the entire spec in the prompt and render cURL from structured data. Retrieval adds latency and failure modes for a problem you don't have. The pipeline in this article earns its complexity when the spec has dozens or hundreds of operations, when parameters are context-dependent, and when a wrong command has real consequences.

If your users are developers who will read the documentation anyway, a well-maintained llms.txt and accurate OpenAPI examples may be enough. The llms.txt convention—a plain-text or Markdown file served at /llms.txt that summarizes what a site offers to LLMs—is a lightweight way to make documentation machine-readable without building a retrieval pipeline . It doesn't generate verified cURL commands, but it reduces the hallucination surface for agents that read docs directly.

And if your API has no OpenAPI spec, this pipeline doesn't apply. The spec is the contract that makes verification possible. Without it, you're back to prompting over prose and hoping the model infers the parameter locations correctly. That's the demo that fails on the first 422.

The decision that matters

The difference between an assistant that generates cURL commands and one that generates working cURL commands is not the model. It's whether the pipeline treats the OpenAPI spec as a contract to be enforced rather than a document to be summarized.

A contract has required fields, allowed values, parameter locations, version-specific semantics, and credential boundaries. A document has prose and structure. If you retrieve the document and hope the model respects the contract, you get commands that look right and fail intermittently. If you enforce the contract at every stage—retrieval, serialization, generation, verification, credential isolation—you get commands that are correct by construction, or rejected before they reach a terminal.

Build the enforcement layer first. Retrieval and generation are easier to tune once the system refuses to emit anything it can't verify.

Post a Comment

Previous Post Next Post