{"slug": "your-api-s-newest-users-are-agents-designing-for-non-human-clients", "title": "Your API's Newest Users Are Agents: Designing for Non-Human Clients", "summary": "A developer demonstrated a pattern for designing APIs that LLM-based agents can call reliably, arguing that human-oriented APIs fail machine clients through vague errors, non-idempotent writes, and bloated responses. The example implements a small Flask ticket API with machine-readable error codes, an Idempotency-Key header, and a published tool schema, paired with a plan-act-observe agent loop that has a hard step limit and success predicate.", "body_md": "Your API's newest users are agents. They don't read docs, they don't browse dashboards, and they don't file support tickets. They parse OpenAPI specs, call endpoints in loops, and expect deterministic, machine-readable responses. If your API was designed for humans clicking buttons, it's already failing this new class of client.\n\nIn this post, I'll walk through a concrete example: building a small \"tool API\" that an LLM-based agent can call. We'll cover the problem, a solution, and a runnable Python implementation. No hand-waving about \"agentic workflows\" — just code you can run and a loop with explicit termination conditions.\n\nHuman-facing APIs optimize for discoverability and forgiveness. Agents optimize for determinism and low token cost. Three specific failure modes show up when agents hit a human-designed API:\n\n`{\"error\": \"bad request\"}` forces the agent to guess. The agent will retry, hallucinate a fix, or give up.\nHere's a minimal example of the kind of handler that causes these problems:\n\n``` python\n# bad_handler.py\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route(\"/create_ticket\", methods=[\"POST\"])\ndef create_ticket_bad():\n    data = request.get_json(silent=True) or {}\n    if \"title\" not in data:\n        return jsonify({\"error\": \"bad request\"}), 400\n    # ... create ticket, no idempotency, returns everything\n    return jsonify({\"ticket\": {\"id\": 1, \"title\": data[\"title\"], \"history\": [...]}})\n```\n\nAn agent calling this has no way to distinguish \"missing field\" from \"malformed JSON\" from \"server error,\" and no safe retry path.\n\nDesign the API for a client that is literal, stateless between calls, and token-budgeted. Concretely:\n\n`code` string the agent can branch on.`Idempotency-Key` header; store the result keyed by it.\nWe'll build a small in-memory ticket API with the properties above, then write an agent loop that uses it. The agent loop is deliberately simple: it's a plan-act-observe loop with a hard step limit and a success predicate. No framework required.\n\n``` python\n# agent_api.py\nimport json\nimport uuid\nfrom dataclasses import dataclass, field, asdict\nfrom typing import Optional\n\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@dataclass\nclass Ticket:\n    id: str\n    title: str\n    status: str = \"open\"\n\ntickets: dict[str, Ticket] = {}\nidempotency_store: dict[str, dict] = {}\n\nTOOL_SCHEMA = {\n    \"name\": \"create_ticket\",\n    \"description\": \"Create a support ticket. Idempotent on Idempotency-Key header.\",\n    \"parameters\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"title\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 200},\n        },\n        \"required\": [\"title\"],\n        \"additionalProperties\": False,\n    },\n}\n\n@app.route(\"/tools\", methods=[\"GET\"])\ndef list_tools():\n    return jsonify({\"tools\": [TOOL_SCHEMA]})\n\n@app.route(\"/create_ticket\", methods=[\"POST\"])\ndef create_ticket():\n    key = request.headers.get(\"Idempotency-Key\")\n    if not key:\n        return jsonify({\"code\": \"missing_idempotency_key\",\n                        \"message\": \"Provide Idempotency-Key header.\"}), 400\n\n    if key in idempotency_store:\n        return jsonify(idempotency_store[key]), 200\n\n    data = request.get_json(silent=True)\n    if not isinstance(data, dict):\n        return jsonify({\"code\": \"invalid_json\",\n                        \"message\": \"Body must be a JSON object.\"}), 400\n\n    title = data.get(\"title\")\n    if not isinstance(title, str) or not title.strip():\n        return jsonify({\"code\": \"invalid_title\",\n                        \"message\": \"'title' must be a non-empty string.\"}), 422\n\n    ticket = Ticket(id=str(uuid.uuid4()), title=title.strip())\n    tickets[ticket.id] = ticket\n    payload = {\"ticket\": asdict(ticket)}\n    idempotency_store[key] = payload\n    return jsonify(payload), 201\n\n@app.route(\"/tickets/<ticket_id>\", methods=[\"GET\"])\ndef get_ticket(ticket_id: str):\n    ticket = tickets.get(ticket_id)\n    if ticket is None:\n        return jsonify({\"code\": \"not_found\",\n                        \"message\": f\"No ticket {ticket_id}.\"}), 404\n    return jsonify({\"ticket\": asdict(ticket)})\n```\n\nKey details: every error has a stable `code`; the response body is small; idempotency is enforced via header, not body, so retries are safe.\n\nThe agent loop below is deliberately framework-free. It defines explicit termination conditions: it stops when the goal is met, when the model returns no tool call, or when `max_steps` is reached. I'm using an OpenAI-chat-completions-style interface here as a stand-in; swap in whichever client you use.\n\n``` python\n# agent_loop.py\nimport json\nimport uuid\nfrom typing import Any\n\nimport requests\n\nAPI = \"http://localhost:5000\"\nMAX_STEPS = 6\n\ndef call_model(messages: list[dict]) -> dict:\n    \"\"\"Return a message dict. Replace with your model client.\"\"\"\n    # Placeholder: in production, call your LLM here.\n    raise NotImplementedError(\"Wire up your model client.\")\n\ndef call_tool(name: str, args: dict) -> dict:\n    if name == \"create_ticket\":\n        r = requests.post(\n            f\"{API}/create_ticket\",\n            json=args,\n            headers={\"Idempotency-Key\": str(uuid.uuid4())},\n            timeout=10,\n        )\n        return {\"status\": r.status_code, \"body\": r.json()}\n    return {\"status\": 400, \"body\": {\"code\": \"unknown_tool\", \"message\": name}}\n\ndef run_agent(goal: str) -> dict:\n    tools = requests.get(f\"{API}/tools\", timeout=10).json()[\"tools\"]\n    messages = [\n        {\"role\": \"system\", \"content\": \"You are an agent. Use tools to satisfy the goal.\"},\n        {\"role\": \"user\", \"content\": goal},\n    ]\n\n    for step in range(MAX_STEPS):\n        msg = call_model(messages)\n        messages.append(msg)\n\n        # Termination condition 1: model produced a final answer.\n        if not msg.get(\"tool_calls\"):\n            return {\"status\": \"done\", \"steps\": step, \"answer\": msg.get(\"content\")}\n\n        for call in msg[\"tool_calls\"]:\n            name = call[\"function\"][\"name\"]\n            args = json.loads(call[\"function\"][\"arguments\"])\n            result = call_tool(name, args)\n            messages.append({\n                \"role\": \"tool\",\n                \"tool_call_id\": call[\"id\"],\n                \"content\": json.dumps(result),\n            })\n\n    # Termination condition 2: step budget exhausted.\n    return {\"status\": \"max_steps_reached\", \"steps\": MAX_STEPS}\n```\n\nThe loop has exactly two exit paths, both explicit. There's no \"keep trying until it works\" branch, which is where most agent bugs live.\n\nIf you extend this to execute code or shell commands, treat the tool boundary as a trust boundary. **Never pass model output to `eval`, `exec`, or `subprocess` with `shell=True`.** If you must run generated code, isolate it in a sandbox (a container with no network, a read-only filesystem, and a hard timeout) and validate arguments against the JSON Schema before execution. The example above avoids this entirely by only allowing a single typed tool with a bounded string parameter.\n\n`eval` on model output is a remote code execution vulnerability with extra steps.\nThe code above is intentionally minimal so you can fork it and add your own tools. Start by auditing your existing API for the three failure modes in the Problem section — that's where the real work is.", "url": "https://wpnews.pro/news/your-api-s-newest-users-are-agents-designing-for-non-human-clients", "canonical_source": "https://dev.to/gu_cci_f94bedb90083e6aab4/your-apis-newest-users-are-agents-designing-for-non-human-clients-5b02", "published_at": "2026-10-07 02:41:25+00:00", "updated_at": "2026-10-07 02:47:38.957305+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "agent-protocols"], "entities": ["Flask", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-api-s-newest-users-are-agents-designing-for-non-human-clients", "markdown": "https://wpnews.pro/news/your-api-s-newest-users-are-agents-designing-for-non-human-clients.md", "text": "https://wpnews.pro/news/your-api-s-newest-users-are-agents-designing-for-non-human-clients.txt", "jsonld": "https://wpnews.pro/news/your-api-s-newest-users-are-agents-designing-for-non-human-clients.jsonld"}}