A luminous AI node sends structured action packets through a dominant multi-stage validation gateway toward abstract business systems while a separate audit lane records the accepted flow
Technical Skills

Connecting LLMs to APIs and Business Workflows Safely

Build a deterministic gateway between LLM tool proposals and business APIs with strict validation, authorization, idempotency, retries, response checks, and durable audit evidence.

Sep 5, 202627 min readMuhammad FarooqLast reviewed: Sep 5, 2026

An LLM can propose a useful business action, but a proposal is not permission and it is not an executable API request. The safe boundary is deterministic application code: it maps a proposed action to an allowlisted registry entry, validates structure and meaning, authorizes the verified actor and tenant, classifies the side effect, applies approval and idempotency policy, executes a fixed API contract, validates the result, and records durable evidence.This guide focuses on that translation boundary. It does not teach unrestricted function calling, delegate credentials to a model, promise exactly-once execution, or treat a successful HTTP status as proof that every business invariant holds. The running example is a local customer-operations fixture with fictional records, simulated dependencies, no real model, no network, and no external side effect.

Put a deterministic gateway between proposal and effect

The application owns every authority-bearing decision.

A safe integration has three planes. The proposal plane supplies verified context to the model and receives a structured candidate action. The application control plane performs schema, semantic, authorization, policy, side-effect, approval, idempotency, and concurrency gates. The execution plane owns credentials, fixed destinations, request construction, response validation, operation state, and audit records.

Decide separately whether the larger system should be a workflow, an agent, or a hybrid; the agents-versus-workflows guide owns that architecture choice. Whichever control style proposes the action, the same deterministic gateway should stand between probabilistic reasoning and a consequential API effect.

The model must not select arbitrary URLs, supply trusted credentials, invent authorization context, decide whether its own request is safe to retry, or write the authoritative audit result. Those capabilities belong to application components whose behavior can be reviewed and tested.

Architecture showing user or event context reaching an LLM that emits a structured proposal, followed by application-owned schema, semantic, authorization, policy, side-effect and approval gates, then a deterministic executor, fixed business API, response validation and durable audit
The model proposes; deterministic application gates authorize and constrain; the executor owns the API effect and evidence.

Treat every tool call as an untrusted proposal

Tool calling communicates intent; application code performs the external operation.

A model-produced tool call is structured data describing a requested action. In the documented function-calling flow, the model returns a tool call and the application executes code on its side before returning a tool result. That separation is the core safety property: the provider is not silently operating your customer database merely because the model emitted a function name.

If a system uses a bounded multi-step loop, keep planning, step budgets, and termination rules in the Python AI agent guide. At the effect boundary, still convert each tool proposal into a fresh application decision rather than inheriting authority from the loop.

Treat the proposal as hostile-but-useful input. It can be malformed, stale, semantically impossible, outside the caller's permissions, inconsistent with current policy, duplicated after a timeout, or valid in shape while pointing at the wrong tenant. Logging that the model was confident changes none of those conditions.

Ownership boundary between a model proposal and an executable command
ConcernModel may provideApplication must own
ActionA registered action name and candidate argumentsRegistry lookup and rejection of unknown actions
IdentityNo trusted identity assertionAuthenticated actor, tenant, resource scope, and permissions
HTTPNo arbitrary method, URL, headers, or credentialsFixed adapter contract, secrets, timeouts, and egress policy
ReliabilityNo trusted retry or idempotency decisionStable operation identity, attempt policy, deduplication, and reconciliation
EvidenceA proposal trace may be diagnosticAuthoritative operation state, API result, policy decision, and audit event

Define an allowlisted action registry

Expose business capabilities, not a generic HTTP client.

The action registry is the application-owned catalog of operations the model may propose. An entry names one narrow business action and binds it to an argument schema, side-effect class, required permission, approval policy, idempotency policy, timeout, retry classification, deterministic executor, and response validator.

Do not offer a tool such as request_url(method, url, headers, body). It collapses routing, authorization, credentials, and request construction into model-controlled data. Prefer narrow names such as read_customer_summary or update_customer_priority whose adapters hard-code the permitted service, path template, method, and field mapping.

Registry entries should be versioned with the workflow and tested as code. Removing or changing an action must not reinterpret an already approved or retried operation under a different contract; durable operation records should retain the registry or policy version used for the decision.

Minimum fields for an application-owned action definition
Registry fieldPurposeApplication invariant
Argument schemaConstrains names, types, enums, lengths, and required fieldsMissing, extra, and wrong-type values are rejected
Side-effect classDistinguishes reads from reversible, sensitive, and irreversible writesRisk policy is selected before execution
Permission and resource scopeConnects action to actor, tenant, and target authorizationModel text never grants access
Approval policyIdentifies operations requiring a bound human decisionApproval references action digest and current context
Idempotency policyDefines stable operation identity and replay handlingSame key plus changed payload becomes a conflict
Execution contractFixes adapter, destination, timeout, and retry classificationNo arbitrary network request is constructed
Response validatorChecks documented result shape and business stateMalformed or mismatched responses cannot become success

Constrain structured output before business logic

A schema narrows syntax; it does not establish authority or truth.

Use a strict structured-output contract for the proposal: an action name, an arguments object, an expected resource version, and any correlation fields the application needs. Where the provider supports strict schema adherence, require all fields and reject additional properties. The application must still validate the received object because providers, models, versions, and integration code can fail in different ways.

Avoid coercion at this boundary. A string that looks like a number is not automatically an integer, a string false is not a boolean, and an unrecognized enum is not a close-enough intent. Coercion hides evidence and can change the meaning of a proposed business action.

The application may generate correlation IDs and idempotency keys after parsing rather than trusting model output. If the proposal schema carries a key for demonstration or transport convenience, the gateway must replace or verify it against application-owned operation state before any real write.

Tips

  • Strict structured output improves shape adherence; it does not prove that the selected action is allowed, meaningful, current, or safe.
  • Reject unknown fields to prevent a later adapter version from accidentally interpreting data that the original policy never reviewed.

Apply semantic validation to current business state

Well-typed arguments can still describe an impossible operation.

Semantic validation asks whether the requested values make sense now. Confirm that the resource exists in the caller's scoped view, identifiers refer to the expected resource type, enumerated transitions are allowed, text and numeric values satisfy domain limits, and required prerequisites still hold.

For a priority update, the schema can prove that priority is one of low, normal, or high. Only current state can establish whether the customer exists, belongs to the relevant tenant, is eligible for a change, and still has the version the proposal was based on. A close-case proposal may be well formed yet blocked because the case is already closed or subject to a legal hold.

Normalize only where the domain contract explicitly permits it. Trimming harmless whitespace can be acceptable; silently rewriting a customer identifier, reason, currency, or status can produce a different operation. Store the canonical validated payload used to compute the operation digest.

Authorize actor, tenant, resource, and action together

Authentication identifies a caller; authorization decides this operation.

Build authorization context from verified application state, not from the prompt or proposal. At minimum, evaluate the actor, tenant, target resource, action permission, relevant attributes, and policy version. A caller with customer:write in tenant B must not update a tenant A customer merely because the model supplied its identifier.

Audience-restricted and least-privilege tokens reduce capability, but a token alone is not the whole policy. The resource service or gateway must refuse a token intended for another audience and enforce resource/action restrictions. Avoid passing a broad upstream credential through a model-facing layer when a narrower service credential or delegated token can perform the registered action.

This also addresses confused-deputy risk. The gateway must not use its own broader privilege to complete an action that the originating actor could not perform. Record both the human or service principal and the execution credential identity without exposing secret material.

  • Deny by default when identity, tenant, permission, resource ownership, or policy version is missing.
  • Re-authorize on replay or delayed execution when policy requires current access, even if the original proposal was valid.
  • Keep denial reasons useful for operators while avoiding unnecessary disclosure of cross-tenant resource existence.

Classify side effects before execution

Risk is a property of the registered operation, not a model label.

Assign each registry action a deterministic side-effect class. The class drives approval, idempotency, retry, timeout, audit, and recovery policy. The model may explain its intent, but it cannot lower the class or declare a write to be read-only.

The categories below are an engineering taxonomy, not a universal legal or compliance classification. An operation that appears reversible in software may still trigger notifications, downstream automations, billing, or human decisions that cannot be undone cleanly.

Illustrative side-effect classes for a business action gateway
ClassIllustrative actionDefault control posture
READ_ONLYRead a customer summaryAuthorize scope, bound timeout, validate response, cache only under an explicit policy
REVERSIBLE_WRITEChange a synthetic priority fieldStable operation key, concurrency precondition, durable result, defined compensation path
SENSITIVE_WRITEAdd an internal note containing governed dataAll write controls plus policy-based approval and data minimization
IRREVERSIBLE_WRITEClose a case under a non-reopenable business ruleEducational category only here; default deny or require a separately governed approval and recovery design

Integrate approval without delegating policy to the model

Approval is one gate in the action pipeline, not a substitute for the others.

When policy requires review, persist a proposal digest, actor, tenant, resource version, action, normalized arguments, policy version, expiry, and reviewer requirements. The eventual approval must bind to that exact operation. An edited payload is a new proposal that needs validation and, when required, a new approval.

Reviewer roles, approve/reject/edit behavior, expiry, stale decisions, and queue design belong to the dedicated human-in-the-loop automation guide. The gateway's responsibility is narrower: require a valid bound approval before execution and re-check current authorization, state, and idempotency afterward.

Do not require a human merely to decorate every low-risk read, and do not assume a reviewer makes an unsafe operation safe. Approval policy should follow consequence, reversibility, uncertainty, data sensitivity, and organizational rules.

Run a synthetic customer-operations gateway

Fourteen local cases exercise the gates without a model or network.

The fixture defines fictional actors, tenants, customers, and support cases for four allowlisted actions: read_customer_summary, update_customer_priority, add_internal_note, and close_support_case. It covers a valid read, a valid reversible write, strict schema rejection, an unknown action, cross-tenant authorization denial, semantic failure, a policy block, an approval requirement, idempotent replay, changed-payload conflict, retryable and non-retryable dependency failures, stale state, and response validation failure.

The evaluator uses only the Python standard library. Its registry is hard-coded application configuration; the fixture cannot add an executor. It deep-copies local state, simulates dependency outcomes, computes a SHA-256 operation digest, records one audit row per case, and derives every count from actual outcomes. It makes no model call, opens no socket, reads no secret, and invokes no real customer system.

Case order is intentional. The valid priority write changes a fictional resource from version 3 to version 4; the same payload and operation key then resolves as a replay before the version gate, a different payload under the same key becomes a conflict, and a new operation still carrying version 3 becomes stale.

Fictional actors, state, proposals, dependency outcomes, and expected gateway decisionsexamples/llm_action_gateway_fixture.json
{
  "fixture": "SYNTHETIC EDUCATIONAL FIXTURE — LLM action gateway; no model, network, or real API",
  "version": 1,
  "actors": {
    "ops-a": {
      "tenant_id": "tenant-a",
      "permissions": [
        "customer:read",
        "customer:write",
        "customer:note",
        "case:close"
      ]
    },
    "ops-b": {
      "tenant_id": "tenant-b",
      "permissions": [
        "customer:read",
        "customer:write"
      ]
    }
  },
  "state": {
    "customers": {
      "cust-104": {
        "tenant_id": "tenant-a",
        "priority": "normal",
        "version": 3
      },
      "cust-900": {
        "tenant_id": "tenant-a",
        "priority": "normal",
        "version": 7
      },
      "cust-205": {
        "tenant_id": "tenant-b",
        "priority": "low",
        "version": 2
      }
    },
    "support_cases": {
      "case-22": {
        "tenant_id": "tenant-a",
        "status": "open",
        "legal_hold": false,
        "version": 5
      },
      "case-99": {
        "tenant_id": "tenant-a",
        "status": "open",
        "legal_hold": true,
        "version": 2
      }
    }
  },
  "cases": [
    {
      "id": "valid-read",
      "actor_id": "ops-a",
      "proposal": {
        "action": "read_customer_summary",
        "arguments": {
          "customer_id": "cust-104"
        },
        "idempotency_key": null,
        "expected_version": 3
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "read_only_completed"
    },
    {
      "id": "valid-reversible-write",
      "actor_id": "ops-a",
      "proposal": {
        "action": "update_customer_priority",
        "arguments": {
          "customer_id": "cust-104",
          "priority": "high"
        },
        "idempotency_key": "priority:cust-104:v3",
        "expected_version": 3
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "write_completed"
    },
    {
      "id": "schema-invalid-extra-field",
      "actor_id": "ops-a",
      "proposal": {
        "action": "update_customer_priority",
        "arguments": {
          "customer_id": "cust-104",
          "priority": "high"
        },
        "idempotency_key": "schema:cust-104:v3",
        "expected_version": 3,
        "unexpected_field": true
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "schema_rejected"
    },
    {
      "id": "unknown-action",
      "actor_id": "ops-a",
      "proposal": {
        "action": "set_customer_score",
        "arguments": {
          "customer_id": "cust-104",
          "score": 5
        },
        "idempotency_key": "score:cust-104:v4",
        "expected_version": 4
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "unknown_action"
    },
    {
      "id": "tenant-authorization-denial",
      "actor_id": "ops-b",
      "proposal": {
        "action": "update_customer_priority",
        "arguments": {
          "customer_id": "cust-104",
          "priority": "low"
        },
        "idempotency_key": "denied:cust-104:v4",
        "expected_version": 4
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "authorization_denied"
    },
    {
      "id": "missing-customer-semantic-failure",
      "actor_id": "ops-a",
      "proposal": {
        "action": "read_customer_summary",
        "arguments": {
          "customer_id": "cust-404"
        },
        "idempotency_key": null,
        "expected_version": 1
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "semantic_rejected"
    },
    {
      "id": "legal-hold-policy-block",
      "actor_id": "ops-a",
      "proposal": {
        "action": "close_support_case",
        "arguments": {
          "case_id": "case-99",
          "reason": "Synthetic duplicate case"
        },
        "idempotency_key": "close:case-99:v2",
        "expected_version": 2
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "policy_blocked"
    },
    {
      "id": "sensitive-note-needs-approval",
      "actor_id": "ops-a",
      "proposal": {
        "action": "add_internal_note",
        "arguments": {
          "customer_id": "cust-104",
          "note": "Synthetic follow-up note."
        },
        "idempotency_key": "note:cust-104:v4",
        "expected_version": 4
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "approval_required"
    },
    {
      "id": "same-operation-replay",
      "actor_id": "ops-a",
      "proposal": {
        "action": "update_customer_priority",
        "arguments": {
          "customer_id": "cust-104",
          "priority": "high"
        },
        "idempotency_key": "priority:cust-104:v3",
        "expected_version": 3
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "idempotent_replay"
    },
    {
      "id": "same-key-changed-payload",
      "actor_id": "ops-a",
      "proposal": {
        "action": "update_customer_priority",
        "arguments": {
          "customer_id": "cust-104",
          "priority": "low"
        },
        "idempotency_key": "priority:cust-104:v3",
        "expected_version": 3
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "idempotency_conflict"
    },
    {
      "id": "retryable-read-dependency-failure",
      "actor_id": "ops-a",
      "proposal": {
        "action": "read_customer_summary",
        "arguments": {
          "customer_id": "cust-900"
        },
        "idempotency_key": null,
        "expected_version": 7
      },
      "approval_reference": null,
      "dependency_results": [
        "temporary_unavailable",
        "temporary_unavailable"
      ],
      "expected": "dependency_failure_retryable"
    },
    {
      "id": "nonretryable-read-dependency-failure",
      "actor_id": "ops-a",
      "proposal": {
        "action": "read_customer_summary",
        "arguments": {
          "customer_id": "cust-900"
        },
        "idempotency_key": null,
        "expected_version": 7
      },
      "approval_reference": null,
      "dependency_results": [
        "invalid_request"
      ],
      "expected": "dependency_failure_nonretryable"
    },
    {
      "id": "stale-version",
      "actor_id": "ops-a",
      "proposal": {
        "action": "update_customer_priority",
        "arguments": {
          "customer_id": "cust-104",
          "priority": "normal"
        },
        "idempotency_key": "stale:cust-104:v3",
        "expected_version": 3
      },
      "approval_reference": null,
      "dependency_results": [
        "success"
      ],
      "expected": "stale_state"
    },
    {
      "id": "invalid-read-response",
      "actor_id": "ops-a",
      "proposal": {
        "action": "read_customer_summary",
        "arguments": {
          "customer_id": "cust-900"
        },
        "idempotency_key": null,
        "expected_version": 7
      },
      "approval_reference": null,
      "dependency_results": [
        "invalid_response"
      ],
      "expected": "response_validation_failed"
    }
  ]
}
Standard-library evaluator for registry, validation, authorization, policy, idempotency, execution, response, and audit gatesexamples/run_llm_action_gateway.py
from __future__ import annotations

import copy
import hashlib
import json
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any


LABEL = "SYNTHETIC EDUCATIONAL FIXTURE — LLM action gateway; no model, network, or real API"
FIXTURE_PATH = Path(__file__).with_name("llm_action_gateway_fixture.json")
FIXTURE_FIELDS = {"fixture", "version", "actors", "state", "cases"}
CASE_FIELDS = {"id", "actor_id", "proposal", "approval_reference", "dependency_results", "expected"}
PROPOSAL_FIELDS = {"action", "arguments", "idempotency_key", "expected_version"}
DEPENDENCY_RESULTS = {"success", "temporary_unavailable", "invalid_request", "invalid_response"}
REQUIRED_OUTCOMES = {
    "read_only_completed",
    "write_completed",
    "schema_rejected",
    "unknown_action",
    "authorization_denied",
    "semantic_rejected",
    "policy_blocked",
    "approval_required",
    "idempotent_replay",
    "idempotency_conflict",
    "dependency_failure_retryable",
    "dependency_failure_nonretryable",
    "stale_state",
    "response_validation_failed",
}
MAX_FIXTURE_ATTEMPTS = 2


@dataclass(frozen=True, slots=True)
class ActionSpec:
    name: str
    argument_fields: tuple[str, ...]
    permission: str
    side_effect_class: str
    approval_required: bool
    idempotency_required: bool
    resource_collection: str
    resource_argument: str
    response_fields: frozenset[str]


REGISTRY = {
    "read_customer_summary": ActionSpec(
        "read_customer_summary", ("customer_id",), "customer:read", "READ_ONLY", False, False,
        "customers", "customer_id", frozenset({"customer_id", "priority", "version"}),
    ),
    "update_customer_priority": ActionSpec(
        "update_customer_priority", ("customer_id", "priority"), "customer:write", "REVERSIBLE_WRITE", False, True,
        "customers", "customer_id", frozenset({"customer_id", "priority", "version", "updated"}),
    ),
    "add_internal_note": ActionSpec(
        "add_internal_note", ("customer_id", "note"), "customer:note", "SENSITIVE_WRITE", True, True,
        "customers", "customer_id", frozenset({"customer_id", "note_id", "version", "added"}),
    ),
    "close_support_case": ActionSpec(
        "close_support_case", ("case_id", "reason"), "case:close", "IRREVERSIBLE_WRITE", True, True,
        "support_cases", "case_id", frozenset({"case_id", "status", "version"}),
    ),
}


def load_fixture() -> dict[str, Any]:
    fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
    if not isinstance(fixture, dict) or set(fixture) != FIXTURE_FIELDS:
        raise ValueError("fixture fields do not match the exact contract")
    if fixture["fixture"] != LABEL or fixture["version"] != 1:
        raise ValueError("fixture label or version is invalid")
    if not isinstance(fixture["actors"], dict) or not fixture["actors"]:
        raise ValueError("actors must be a non-empty object")
    for actor_id, actor in fixture["actors"].items():
        if not isinstance(actor_id, str) or not isinstance(actor, dict) or set(actor) != {"tenant_id", "permissions"}:
            raise ValueError("actor contract is invalid")
        if not isinstance(actor["tenant_id"], str) or not isinstance(actor["permissions"], list):
            raise ValueError("actor fields have invalid types")
        if not all(isinstance(value, str) and value for value in actor["permissions"]):
            raise ValueError("actor permissions must be non-empty strings")
    if not isinstance(fixture["state"], dict) or set(fixture["state"]) != {"customers", "support_cases"}:
        raise ValueError("state contract is invalid")
    if not isinstance(fixture["cases"], list) or not fixture["cases"]:
        raise ValueError("cases must be a non-empty list")

    seen_ids: set[str] = set()
    expected_outcomes: set[str] = set()
    for case in fixture["cases"]:
        if not isinstance(case, dict) or set(case) != CASE_FIELDS:
            raise ValueError("case fields do not match the exact contract")
        if not isinstance(case["id"], str) or not case["id"] or case["id"] in seen_ids:
            raise ValueError("case IDs must be unique non-empty strings")
        seen_ids.add(case["id"])
        if case["actor_id"] not in fixture["actors"]:
            raise ValueError(f"unknown fixture actor: {case['actor_id']}")
        approval = case["approval_reference"]
        if approval is not None and (not isinstance(approval, str) or not approval):
            raise ValueError("approval_reference must be null or a non-empty string")
        results = case["dependency_results"]
        if not isinstance(results, list) or not 1 <= len(results) <= MAX_FIXTURE_ATTEMPTS:
            raise ValueError("dependency_results must contain one or two events")
        if any(result not in DEPENDENCY_RESULTS for result in results):
            raise ValueError("dependency_results contains an unknown event")
        if case["expected"] not in REQUIRED_OUTCOMES:
            raise ValueError(f"unknown expected outcome: {case['expected']}")
        expected_outcomes.add(case["expected"])
    missing = REQUIRED_OUTCOMES - expected_outcomes
    if missing:
        raise ValueError(f"fixture lacks required outcomes: {sorted(missing)}")
    return fixture


def valid_proposal(proposal: Any) -> bool:
    return (
        isinstance(proposal, dict)
        and set(proposal) == PROPOSAL_FIELDS
        and isinstance(proposal["action"], str)
        and bool(proposal["action"])
        and isinstance(proposal["arguments"], dict)
        and (proposal["idempotency_key"] is None or (isinstance(proposal["idempotency_key"], str) and bool(proposal["idempotency_key"])))
        and type(proposal["expected_version"]) is int
        and proposal["expected_version"] > 0
    )


def valid_arguments(spec: ActionSpec, proposal: dict[str, Any]) -> bool:
    arguments = proposal["arguments"]
    if set(arguments) != set(spec.argument_fields):
        return False
    if not all(isinstance(arguments[field], str) and bool(arguments[field]) for field in spec.argument_fields):
        return False
    if "priority" in arguments and arguments["priority"] not in {"low", "normal", "high"}:
        return False
    if "note" in arguments and len(arguments["note"]) > 120:
        return False
    key = proposal["idempotency_key"]
    return (key is None) if not spec.idempotency_required else isinstance(key, str) and bool(key)


def operation_digest(proposal: dict[str, Any]) -> str:
    operation = {
        "action": proposal["action"],
        "arguments": proposal["arguments"],
        "expected_version": proposal["expected_version"],
    }
    encoded = json.dumps(operation, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


class Gateway:
    def __init__(self, actors: dict[str, Any], state: dict[str, Any]):
        self.actors = actors
        self.state = copy.deepcopy(state)
        self.idempotency: dict[str, dict[str, Any]] = {}
        self.executed_keys: set[str] = set()
        self.approvals: dict[str, dict[str, str]] = {}
        self.audit: list[dict[str, str]] = []
        self.metrics: Counter[str] = Counter()

    def process(self, case: dict[str, Any]) -> str:
        outcome = self._evaluate(case)
        proposal = case["proposal"]
        action = proposal.get("action", "<invalid>") if isinstance(proposal, dict) else "<invalid>"
        self.audit.append({
            "correlation_id": case["id"],
            "actor_id": case["actor_id"],
            "proposed_action": str(action),
            "outcome": outcome,
        })
        return outcome

    def _evaluate(self, case: dict[str, Any]) -> str:
        proposal = case["proposal"]
        if not valid_proposal(proposal):
            return "schema_rejected"
        spec = REGISTRY.get(proposal["action"])
        if spec is None:
            return "unknown_action"
        if not valid_arguments(spec, proposal):
            return "schema_rejected"

        arguments = proposal["arguments"]
        resource = self.state[spec.resource_collection].get(arguments[spec.resource_argument])
        if resource is None:
            return "semantic_rejected"
        actor = self.actors[case["actor_id"]]
        if spec.permission not in actor["permissions"] or resource["tenant_id"] != actor["tenant_id"]:
            return "authorization_denied"
        if spec.name == "close_support_case" and resource["status"] != "open":
            return "semantic_rejected"
        if spec.name == "close_support_case" and resource["legal_hold"] is True:
            return "policy_blocked"

        digest = operation_digest(proposal)
        if spec.approval_required:
            approval = self.approvals.get(case["approval_reference"] or "")
            if approval != {"action": spec.name, "digest": digest, "actor_id": case["actor_id"]}:
                return "approval_required"

        if spec.idempotency_required:
            key = proposal["idempotency_key"]
            assert isinstance(key, str)
            previous = self.idempotency.get(key)
            if previous is not None:
                return "idempotent_replay" if previous["digest"] == digest else "idempotency_conflict"

        if resource["version"] != proposal["expected_version"]:
            return "stale_state"

        response, dependency_outcome = self._call_executor(spec, proposal, resource, case["dependency_results"])
        if dependency_outcome is not None:
            return dependency_outcome
        assert response is not None
        if not self._valid_response(spec, proposal, response):
            return "response_validation_failed"

        if spec.idempotency_required:
            key = proposal["idempotency_key"]
            assert isinstance(key, str)
            self.idempotency[key] = {"digest": digest, "response": response}
            return "write_completed"
        return "read_only_completed"

    def _call_executor(
        self,
        spec: ActionSpec,
        proposal: dict[str, Any],
        resource: dict[str, Any],
        dependency_results: list[str],
    ) -> tuple[dict[str, Any] | None, str | None]:
        for attempt, result in enumerate(dependency_results, start=1):
            if result == "temporary_unavailable":
                retry_is_bounded_and_safe = (
                    spec.side_effect_class == "READ_ONLY"
                    and attempt < len(dependency_results)
                    and attempt < MAX_FIXTURE_ATTEMPTS
                )
                if retry_is_bounded_and_safe:
                    self.metrics["bounded_retries"] += 1
                    continue
                return None, "dependency_failure_retryable"
            if result == "invalid_request":
                return None, "dependency_failure_nonretryable"
            response = self._execute(spec, proposal, resource, authorized=True)
            if result == "invalid_response":
                response.pop("version", None)
            return response, None
        raise AssertionError("dependency sequence exhausted without an outcome")

    def _execute(
        self,
        spec: ActionSpec,
        proposal: dict[str, Any],
        resource: dict[str, Any],
        *,
        authorized: bool,
    ) -> dict[str, Any]:
        if not authorized:
            self.metrics["unauthorized_side_effects"] += 1
            raise AssertionError("executor called without application authorization")
        arguments = proposal["arguments"]
        if spec.name == "read_customer_summary":
            return {"customer_id": arguments["customer_id"], "priority": resource["priority"], "version": resource["version"]}

        key = proposal["idempotency_key"]
        assert isinstance(key, str)
        if key in self.executed_keys:
            self.metrics["duplicate_side_effects"] += 1
        self.executed_keys.add(key)
        self.metrics["executed_side_effects"] += 1

        if spec.name == "update_customer_priority":
            resource["priority"] = arguments["priority"]
            resource["version"] += 1
            return {"customer_id": arguments["customer_id"], "priority": resource["priority"], "version": resource["version"], "updated": True}
        if spec.name == "add_internal_note":
            resource["version"] += 1
            return {"customer_id": arguments["customer_id"], "note_id": f"note-{resource['version']}", "version": resource["version"], "added": True}
        if spec.name == "close_support_case":
            resource["status"] = "closed"
            resource["version"] += 1
            return {"case_id": arguments["case_id"], "status": resource["status"], "version": resource["version"]}
        raise AssertionError("registry executor is missing")

    @staticmethod
    def _valid_response(spec: ActionSpec, proposal: dict[str, Any], response: Any) -> bool:
        if not isinstance(response, dict) or set(response) != set(spec.response_fields):
            return False
        arguments = proposal["arguments"]
        if response.get(spec.resource_argument) != arguments[spec.resource_argument]:
            return False
        if type(response.get("version")) is not int:
            return False
        if spec.name == "read_customer_summary":
            return response["priority"] in {"low", "normal", "high"}
        if spec.name == "update_customer_priority":
            return response["priority"] == arguments["priority"] and response["updated"] is True
        if spec.name == "add_internal_note":
            return isinstance(response["note_id"], str) and response["added"] is True
        return response.get("status") == "closed"


def main() -> int:
    fixture = load_fixture()
    gateway = Gateway(fixture["actors"], fixture["state"])
    outcomes: Counter[str] = Counter()
    failures: list[str] = []

    for case in fixture["cases"]:
        actual = gateway.process(case)
        outcomes[actual] += 1
        if actual != case["expected"]:
            failures.append(f"{case['id']}: expected {case['expected']}, got {actual}")

    passed = len(fixture["cases"]) - len(failures)
    completed = outcomes["read_only_completed"] + outcomes["write_completed"] + outcomes["idempotent_replay"]
    lines = [
        LABEL,
        f"cases={len(fixture['cases'])}",
        f"passed={passed}",
        f"completed={completed}",
        f"read_only_completed={outcomes['read_only_completed']}",
        f"write_completed={outcomes['write_completed']}",
        f"approval_required={outcomes['approval_required']}",
        f"schema_rejected={outcomes['schema_rejected']}",
        f"unknown_action={outcomes['unknown_action']}",
        f"authorization_denied={outcomes['authorization_denied']}",
        f"semantic_rejected={outcomes['semantic_rejected']}",
        f"policy_blocked={outcomes['policy_blocked']}",
        f"idempotent_replay={outcomes['idempotent_replay']}",
        f"idempotency_conflict={outcomes['idempotency_conflict']}",
        f"retryable_dependency_failures={outcomes['dependency_failure_retryable']}",
        f"nonretryable_dependency_failures={outcomes['dependency_failure_nonretryable']}",
        f"stale_state={outcomes['stale_state']}",
        f"response_validation_failed={outcomes['response_validation_failed']}",
        f"bounded_retries={gateway.metrics['bounded_retries']}",
        f"executed_side_effects={gateway.metrics['executed_side_effects']}",
        f"duplicate_side_effects={gateway.metrics['duplicate_side_effects']}",
        f"unauthorized_side_effects={gateway.metrics['unauthorized_side_effects']}",
    ]
    lines.extend(f"FAIL {failure}" for failure in failures)
    if len(gateway.audit) != len(fixture["cases"]):
        lines.append("FAIL audit record count mismatch")
    sys.stdout.buffer.write("\n".join(lines).encode("utf-8"))
    clean = not failures and len(gateway.audit) == len(fixture["cases"])
    safe = gateway.metrics["duplicate_side_effects"] == 0 and gateway.metrics["unauthorized_side_effects"] == 0
    return 0 if clean and safe else 1


if __name__ == "__main__":
    raise SystemExit(main())

Expected output

SYNTHETIC EDUCATIONAL FIXTURE — LLM action gateway; no model, network, or real API
cases=14
passed=14
completed=3
read_only_completed=1
write_completed=1
approval_required=1
schema_rejected=1
unknown_action=1
authorization_denied=1
semantic_rejected=1
policy_blocked=1
idempotent_replay=1
idempotency_conflict=1
retryable_dependency_failures=1
nonretryable_dependency_failures=1
stale_state=1
response_validation_failed=1
bounded_retries=1
executed_side_effects=1
duplicate_side_effects=0
unauthorized_side_effects=0

Tips

  • SYNTHETIC EDUCATIONAL FIXTURE only — not a production gateway, benchmark, provider conformance test, or evidence of business outcomes.
  • The sensitive and irreversible proposals do not execute: one needs approval and one is blocked by policy.
  • The runner writes UTF-8 bytes with LF separators and no trailing newline so the stored output can be compared byte-for-byte.

Build idempotency around a stable business operation

Duplicate suppression needs application-owned identity and durable state.

An idempotency key should identify one stable business operation, not one HTTP attempt and not one model generation. The application mints the key when it accepts a candidate operation, persists it with a canonical request digest, and reuses it for every safe retry or recovery check. A repeat with the same key and same digest can return the stored outcome; the same key with a changed payload must be a conflict.

Persist operation states such as proposed, validated, authorized, approval_pending, executing, succeeded, failed_nonretryable, outcome_unknown, and reconciliation_required. Atomically claim an operation before executing the write so concurrent workers cannot both pass a check-then-act race. Store the external provider's operation or resource identifier when available.

HTTP defines some methods as idempotent by semantics, but POST is not universally idempotent. A provider may offer keyed deduplication for a particular POST contract. Stripe, for example, documents its own key retention, parameter comparison, and replay behavior; those details are provider-specific and must not be copied to another API without its contract.

Idempotency does not create exactly-once execution. Durable application state, a unique operation key, atomic claims, provider-supported deduplication, conditional writes, response validation, and reconciliation reduce duplicate effects and make uncertainty recoverable. Downstream services can still have their own side effects, retention windows, and failure modes.

Application behavior for an operation key
Observed stateGateway decisionExternal effect
No record; payload digest is newCreate and atomically claim the durable operationExecute only after every gate passes
Same key and same digest; stored successReturn the recorded result as an idempotent replayDo not execute again
Same key and different digestReject as idempotency conflictDo not execute
Executing or outcome_unknownDo not launch a competing write; inspect provider status or reconcileUnknown until evidence resolves it
Failed before endpoint execution is provenRetry only if the adapter contract and operation safety allow itReuse the same operation identity

Retry only classified failures under a safe contract

Backoff is timing policy; it is not permission to repeat a write.

Classify failures by phase and operation semantics. A selected read may retry a connection reset, timeout, 429, or 503 with bounded attempts, jittered backoff, and a total deadline. A validation error, authentication failure, authorization denial, policy block, stale precondition, or malformed request requires correction or escalation rather than blind replay.

Retry-After can tell a client how long it ought to wait, and a 429 or 503 can be retryable under a provider contract. The header does not make a non-idempotent write safe. Before retrying a write, require a stable operation identity plus explicit evidence that the endpoint contract supports safe repetition or that the first attempt never reached execution.

Budget retries across layers. If the SDK, adapter, queue worker, and workflow each retry three times, one proposal can cause dozens of attempts. Choose one layer to own the policy or propagate an attempt budget and deadline through every layer.

Illustrative retry classification; the provider contract remains authoritative
FailureDefault classificationNext action
Schema, semantic, authorization, or policy rejectionNon-retryable without changed input or authorityRecord the gate decision; do not call the API
401 or 403Credential or permission issueRefresh only through an authorized credential path or escalate; do not loop
409 or 412 stale/conflict resultFresh state requiredRead current state, form a new proposal, and re-authorize
429 or selected 503 on a readPotentially retryableHonor documented delay, bound attempts, and preserve the deadline
Connection loss during a writePotentially ambiguousDo not assume failure; use idempotency/status evidence and reconcile
Malformed or mismatched 2xx bodyResponse-contract failureQuarantine the result and investigate; never silently accept

Treat a timeout after a write as an ambiguous outcome

No response does not mean no side effect.

A connection can fail after the provider has accepted or committed a write but before the application receives the response. Marking the operation failed and generating a new key can duplicate the effect. Immediate retry is safe only under a known application or provider idempotency contract using the same stable operation identity.

Move the durable operation to outcome_unknown or reconciliation_required. Query a documented operation-status endpoint, retrieve the target resource and compare expected state, or look up the provider idempotency result when supported. Reconciliation must validate resource identity, tenant, payload digest, version, and business state rather than treating any nearby change as proof.

If no authoritative status exists, route the operation to bounded manual investigation with the available request and provider correlation IDs. Do not ask the model to guess whether the write happened, and do not label the absence of an exception as success.

Let the executor own the external API contract

The adapter converts a validated business command into one fixed integration.

A deterministic executor selects the base URL, path template, HTTP method, content type, allowed headers, credential source, timeout, retry policy, request mapping, and response validator from the registry. Model arguments populate only named business fields after validation; they never become an arbitrary URL, header name, query expression, or serialized request body.

The separate FastAPI architecture guide covers HTTP acceptance, job boundaries, provider adapters, health, and deployment structure. Here the important invariant is that any framework route calls the same application gateway instead of turning model output directly into an outbound request.

Give adapters explicit connect, read, write, and total deadlines where the client permits them. Limit response size, accepted content types, redirects, and destination resolution. Attach an application correlation ID and the stable operation key without leaking prompt content or secrets into headers.

Keep secrets and destinations outside model-visible data

Credentials are executor configuration, not tool arguments.

Resolve credentials from a server-side secret manager or protected runtime configuration only after authorization. The proposal should reference a registered connection or capability, never contain an API key, bearer token, private endpoint, signing secret, or arbitrary hostname. Redact secrets from prompts, traces, exceptions, audit payloads, and copied tool results.

Use least-privilege credentials restricted to the intended audience, resources, and actions. Separate credentials across tenants or environments when the provider and risk model require it, rotate them through an auditable process, and disable unused capabilities. Client-side applications must not receive a server credential merely because they initiated the request.

Constrain outbound traffic to registered HTTPS destinations and documented redirect behavior. This is a defensive control against model-proposed or data-derived destinations escaping the intended integration boundary; it is not a guide to targeting internal services or bypassing network controls.

  • Store secret identifiers, not secret values, in action registry and durable operation records.
  • Sanitize provider errors before returning context to the model or end user.
  • Review model and observability retention so request or response fields containing governed data are minimized.

Validate the provider response before accepting success

HTTP success semantics and business success are related but not identical.

A 2xx response has HTTP success semantics defined by its method and status code. It is still insufficient by itself to establish every provider-specific business invariant. Parse only the documented content type, validate the response schema, require the expected resource or operation identifier, verify the tenant or account when returned, and normalize the documented business status.

A 202 response means the request was accepted while processing is incomplete; it may never be acted on. Store the provider operation identifier and poll or receive events according to the provider contract until a terminal result, deadline, or reconciliation state is reached.

Treat a syntactically valid response with the wrong resource ID, missing version, impossible transition, or unexpected success shape as response_validation_failed. Preserve a redacted body digest and correlation metadata for diagnosis, but do not teach the model that the operation succeeded.

Response checks after the HTTP request returns
LayerQuestionFailure handling
ProtocolIs the status, content type, size, and redirect behavior allowed?Classify according to the adapter contract
SchemaAre required fields present with exact types and no prohibited extras?Reject or quarantine the result
IdentityDoes the response refer to the intended operation, account, and resource?Treat mismatch as a security and correctness failure
StateDoes the provider state represent completed, pending, partial, or failed work?Persist normalized state; do not flatten 202 or partial results into success
VersionDoes the returned version or ETag match the accepted transition?Record conflict or start reconciliation

Verify webhooks as untrusted asynchronous input

An event is another input boundary, not an automatic completion signal.

When an API completes asynchronously, receive the webhook's raw bytes and verify the provider's documented signature scheme before parsing or acting. Use TLS, check the intended endpoint or account context, enforce a bounded timestamp or replay policy where specified, and deduplicate a stable event identifier in durable storage.

Signature validity establishes only what the specific scheme covers; it does not authorize every downstream action and it does not guarantee that unsigned fields were immutable. Map the event to a known pending operation, verify its resource and tenant, and, for consequential transitions, fetch current provider state through the registered adapter.

Acknowledge delivery according to the provider contract after durable receipt. Process asynchronously when needed, make the handler idempotent, record duplicate and out-of-order events, and never include a signing secret in model context.

  • Unknown operation or resource: retain minimal forensic metadata and do not create a new effect.
  • Valid duplicate event: return the prior processing result without repeating downstream work.
  • Out-of-order event: compare sequence, timestamp, or current provider state before changing the operation record.
  • Invalid signature or expired replay window: reject without exposing verification internals.

Reject stale proposals and concurrent writes

Idempotency controls duplicates; preconditions control lost updates.

Capture a resource version, strong ETag, or equivalent concurrency token when context is assembled. Immediately before execution, confirm the token still represents current state. For an HTTP API that documents conditional updates, send the exact strong ETag in If-Match; a failed precondition normally produces 412 and the method is not applied.

Do not use a weak ETag for strong If-Match comparison, and do not assume every provider supports conditional requests. With an internal database, use an atomic update constrained by both resource ID and version, then require exactly one affected row. A stale result returns to fresh context and a new proposal rather than silently overwriting the newer state.

Conditional execution and idempotency solve different problems. The first prevents a decision based on old state from winning a race; the second suppresses duplicate attempts for the same accepted operation. Consequential writes often need both.

Separate durable audit evidence from operational telemetry

Audit explains authority and effects; observability explains system behavior.

A durable audit event should identify the correlation and operation IDs, actor and tenant, registered action and version, redacted argument digest, resource and expected version, authorization and policy decisions, approval reference, idempotency key or digest reference, executor attempt, provider correlation ID, normalized outcome, and timestamps. Protect integrity, access, retention, and deletion according to the data classification.

The model's explanation can be stored as non-authoritative context, clearly labeled as generated text. The audit truth comes from deterministic gates and observed provider evidence. Do not log credentials, raw tokens, signing secrets, or unnecessary customer content merely to make a trace verbose.

Operational telemetry answers different questions: validation-rejection rate, authorization denials, approval wait, attempt count, latency by action, retry exhaustion, ambiguous outcomes, response-contract failures, reconciliation age, and queue depth. Use low-cardinality metric dimensions and keep identifiers in access-controlled traces or logs.

Correlate the proposal, durable operation, outbound attempt, provider response, webhook, and reconciliation job. A single trace is helpful, but the durable operation record must survive trace sampling and retention.

Use a failure taxonomy with an explicit owner

Different failures require different correction paths.

Map every terminal and non-terminal result to a named class, an owning component, whether input or authority must change, whether retry is allowed, and what evidence must be retained. A generic tool_failed status encourages blind retry and hides whether the model, policy, state, credentials, provider, or response contract actually failed.

Return only the minimum safe context to the model. It may reform a schema-invalid proposal or select a different registered action, but it must not be invited to work around an authorization denial, policy block, secret failure, or destination restriction.

Failure taxonomy for an LLM-to-business-API action gateway
Failure classExamplePrimary owner and response
Proposal/schemaMissing field, extra field, wrong type, unknown actionPrompt/schema or caller contract; reject before policy and execution
SemanticMissing resource or invalid state transitionDomain service; refresh scoped context or require a new proposal
Authorization/policyWrong tenant, missing permission, legal holdIdentity/policy owner; deny without API execution
ApprovalMissing, expired, edited, or stale approvalReview workflow; obtain a newly bound decision when policy permits
Idempotency/concurrencyChanged payload under one key or version mismatchOperation/state layer; return conflict and reconcile or re-propose
Dependency retryableSelected read receives a transient 503Adapter; bounded retry under deadline and documented semantics
Dependency non-retryableInvalid request or unsupported operationAdapter/integration owner; stop and correct contract
Ambiguous outcomeConnection lost after a write was sentOperation owner; status lookup and reconciliation, not blind replay
Response contract2xx body omits resource version or identifies another resourceAdapter/provider owner; quarantine result and alert
Audit/telemetryOperation completed but durable evidence write failedPlatform owner; treat atomicity gap as a blocking design defect

State exactly what the executable evidence demonstrates

Passing a local fixture is implementation evidence, not production proof.

The artifacts make the decision order inspectable and executable. They are deliberately small enough to audit, but their passing output cannot establish provider behavior, security, latency, throughput, availability, or business value.

Evidence boundary for the synthetic LLM action gateway
EvidenceDemonstratesDoes not prove
Strict fixture and proposal checksExact top-level fields, type checks, allowlisted action lookup, and rejection paths in this evaluatorProvider structured-output conformance or complete validation for a real domain
Actor, tenant, permission, and policy gatesA deterministic denial occurs before the simulated executorA production identity system, RBAC review, isolation audit, or legal compliance
Operation digest and local idempotency mapSame key plus same payload replays while changed payload conflictsDurable multi-worker deduplication, provider idempotency, or exactly-once execution
Bounded read retry simulationOne retry is counted for an authored transient dependency sequenceNetwork reliability, correct policy for another API, latency, or throughput
Stale version caseThe evaluator rejects a new operation based on an old versionA real ETag implementation or absence of race conditions
Response validatorA missing required field produces response_validation_failedThat a real provider always returns correct or trustworthy data
Audit list and derived countersEvery synthetic case receives an outcome record and metrics come from executionTamper resistance, durable retention, observability coverage, or an SLA
Stored expected outputA reproducible local Python 3.12 result when byte comparison passesModel quality, business success, cost savings, accuracy, scale, or production readiness

Harden the gateway before production

The fixture is a specification seed, not deployable infrastructure.

Replace the in-memory state and idempotency map with transactional durable storage, unique constraints, atomic operation claims, bounded leases, recovery workers, and a reconciliation queue. Define which operation and audit writes must commit together, and test crash points before and after every external call.

Contract-test each adapter against provider documentation and a controlled sandbox. Add deterministic fixtures for schema boundaries, authorization matrices, tenant isolation, policy versions, duplicate delivery, stale updates, 202 polling, malformed responses, webhook replay, ambiguous writes, and secret redaction. Fault-inject timeouts at connect, request-send, response-header, and body-read phases.

Apply outbound destination controls, secret rotation, least-privilege credentials, encrypted storage, retention rules, audit access review, dependency scanning, and incident runbooks. Review regulated or high-impact workflows with security, privacy, legal, and domain owners rather than treating a code checklist as approval.

Release gradually with action-specific kill switches, shadow validation, low-risk scopes, explicit budgets, dashboards, alerts, and manual recovery tools. Measure gate outcomes and reconciliation age before expanding authority; do not infer reliability from one happy-path demo.

  • Version proposal schema, registry, policy, adapter, and response contract independently.
  • Make irreversible actions default-deny until their approval, compensation, notification, and recovery paths are reviewed.
  • Test that no rejected case reaches the network and no secret appears in model-visible or operator-visible output.
  • Document provider-specific idempotency retention, retry, conditional request, asynchronous completion, and webhook contracts.
  • Run disaster-recovery and concurrency tests against the durable operation ledger before increasing traffic or consequence.

FAQ

Does an LLM tool call execute a business API automatically?

Not in the application-owned function-calling flow described here. The model returns a structured tool proposal; application code decides whether and how to execute a registered function. That code must validate, authorize, constrain, execute, verify, and audit the operation.

Does an idempotency key guarantee exactly-once execution?

No. A key is one control within an application or provider contract. Reliable handling also needs durable operation state, a canonical payload digest, atomic claims, duplicate detection, concurrency preconditions, response validation, and reconciliation for ambiguous outcomes.

Should a timed-out business API write be retried immediately?

Not by default. The provider may have applied the write before the response was lost. Reuse the same stable operation identity only under a documented safe-retry contract, or query status and reconcile before another effect is attempted.

Does every LLM-proposed write need human approval?

No universal rule makes every write reviewable or every reviewed write safe. Approval should follow an application-owned risk policy based on consequence, reversibility, sensitivity, uncertainty, and organizational requirements, while all other validation and authorization gates remain mandatory.

Can the model choose an arbitrary URL if the domain is validated later?

This design does not expose a generic HTTP client. The registry binds each action to a fixed adapter and approved destination; model output supplies only narrowly validated business arguments. Credentials and network routing remain outside model-visible data.

Does the executable fixture contact a real model or business system?

No. It is a standard-library-only synthetic educational fixture using fictional actors and records. Dependency outcomes and responses are simulated locally, and the evaluator performs no network call or real side effect.

Sources

Primary and authoritative sources reviewed for this article.

Conclusion

Safe LLM-to-API integration is not achieved by giving a model better instructions to be careful. It comes from an application-owned action gateway that treats every proposal as untrusted, authorizes against verified context, constrains execution to a registered contract, protects writes with durable identity and concurrency controls, validates provider outcomes, and preserves audit evidence. The model may propose; deterministic software decides and acts.