{"slug": "designing-tools-an-llm-won-t-misuse", "title": "Designing tools an LLM won't misuse", "summary": "A developer from Loop & Retry argues that most LLM agent bugs stem from poorly designed tools rather than the model itself, and outlines four properties—legible schemas, validating boundaries, recoverable errors, and idempotency—that make tools harder to misuse. The post provides concrete examples of schema design, such as using enums and bounds to constrain model behavior.", "body_md": "*Originally published on Loop & Retry — field notes on building LLM agents that survive production.*\n\nMost agent bugs I've chased weren't in the model. They were in the tools — specifically, in the gap between what a tool's schema *implied* it wanted and what it actually did with what it got. The model is a caller that reads your parameter names and descriptions, forms a plausible theory of how the tool works, and acts on that theory under uncertainty. When it misuses a tool, the usual cause is that the tool let it.\n\nYou can't make the caller deterministic. You can make the tool hard to misuse. Four properties do most of the work: a **legible schema**, a **validating boundary**, **recoverable errors**, and **idempotency**. Here's each, with the failing version and the fix.\n\nThe schema is the entire spec the model gets. It can't read your code, your docstrings elsewhere, or the ticket that explains the edge case. If the contract isn't in the name, the type, and the description, it doesn't exist. Here's a tool that leaks its contract:\n\n```\n# BAD: what does any of this mean, and what's allowed?\n{\n    \"name\": \"search\",\n    \"description\": \"Search for items.\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"query\": {\"type\": \"string\"},\n            \"filters\": {\"type\": \"string\"},   # a string of... what?\n            \"options\": {\"type\": \"object\"},   # anything goes\n            \"limit\": {\"type\": \"integer\"},\n        },\n        \"required\": [\"query\"],\n    },\n}\n```\n\nEvery field here invites a guess. `filters`\n\nis a string, so the model will invent a syntax — `\"status:open\"`\n\n, or `\"status=open,priority=high\"`\n\n, or JSON, depending on its mood — and you'll parse whichever it picked. `options`\n\nis a free object, which means the model can pass anything and you handle nothing reliably. `limit`\n\nhas no bounds, so you'll eventually get `limit: 10000`\n\n. The name `search`\n\ndoesn't say search *what*.\n\nThe fix is to make illegal states unrepresentable in the schema itself, and to spend words on the description where the type can't carry the meaning:\n\n```\n# GOOD: the schema is the spec; enums close off invention; ranges bound blast radius\n{\n    \"name\": \"search_support_tickets\",\n    \"description\": (\n        \"Search the customer support ticket database. Returns tickets ordered \"\n        \"by last-updated, newest first. Use `status` and `assignee_email` to \"\n        \"narrow results; omit them to search all tickets. Does NOT search \"\n        \"archived tickets older than 90 days — use `search_ticket_archive` for those.\"\n    ),\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"query\": {\n                \"type\": \"string\",\n                \"description\": \"Free-text search over ticket subject and body.\",\n            },\n            \"status\": {\n                \"type\": \"string\",\n                \"enum\": [\"open\", \"pending\", \"resolved\", \"closed\"],\n                \"description\": \"Filter to one status. Omit to include all statuses.\",\n            },\n            \"assignee_email\": {\n                \"type\": \"string\",\n                \"description\": \"Filter to tickets assigned to this exact email address.\",\n            },\n            \"limit\": {\n                \"type\": \"integer\",\n                \"minimum\": 1,\n                \"maximum\": 50,\n                \"description\": \"Max results to return (1-50). Default 20.\",\n            },\n        },\n        \"required\": [\"query\"],\n    },\n}\n```\n\nWhat changed, and why each matters:\n\n`search_support_tickets`\n\n, not `search`\n\n. When an agent has fifteen tools, a bare `search`\n\ncompetes with `search_docs`\n\nand `search_users`\n\nfor the same intent, and the model picks wrong. Name the noun.`enum`\n\nreplaces a free string.`minimum`\n\n/`maximum`\n\nbound the blast radius.Keep the surface small, too. Every optional parameter is another axis the model can get wrong. If you have a tool with twelve optional knobs, you probably have three or four tools wearing a trench coat — split them by intent so each call has an obvious shape.\n\nA legible schema constrains what the model *can* send. It doesn't guarantee what the model *should* send — semantics the schema can't express (this email must exist, this date range must be non-empty, this ID must belong to the current user). Validate those at the top of the tool, before any side effect, and when you reject, say why in a way the model can act on.\n\n```\n# BAD: the schema passed, so we assume the values are sane, and blow up if not\ndef create_calendar_event(title, start, end, attendee_emails):\n    event = calendar.insert(          # raises deep in the client on bad input\n        title=title, start=start, end=end, attendees=attendee_emails,\n    )\n    return {\"event_id\": event.id}\n```\n\nIf `end`\n\nis before `start`\n\n, or `attendee_emails`\n\ncontains a typo'd address, this fails somewhere inside the calendar client with an exception the model never sees cleanly — or worse, it half-succeeds. Compare:\n\n```\n# GOOD: validate first; failures are data the model can recover from\nfrom datetime import datetime\n\ndef create_calendar_event(title, start, end, attendee_emails):\n    errors = []\n    try:\n        t0, t1 = datetime.fromisoformat(start), datetime.fromisoformat(end)\n        if t1 <= t0:\n            errors.append(\n                f\"`end` ({end}) must be after `start` ({start}). \"\n                \"Both must be ISO-8601, e.g. 2026-07-10T14:00:00-04:00.\"\n            )\n    except ValueError:\n        errors.append(\n            \"`start`/` end` must be ISO-8601 datetimes, \"\n            \"e.g. 2026-07-10T14:00:00-04:00.\"\n        )\n\n    unknown = [e for e in attendee_emails if not directory.exists(e)]\n    if unknown:\n        errors.append(\n            f\"These attendees are not in the directory: {unknown}. \"\n            \"Check spelling, or call `search_people` to find the correct address.\"\n        )\n\n    if errors:\n        return {\"ok\": False, \"errors\": errors}   # returned, not raised\n\n    event = calendar.insert(title=title, start=start, end=end,\n                            attendees=attendee_emails)\n    return {\"ok\": True, \"event_id\": event.id}\n```\n\nThe point isn't defensive coding for its own sake. It's that a validating boundary turns \"the tool exploded\" into \"the tool told the model what to fix,\" and a model can act on the second. Which brings up the property people skip.\n\nWhen a tool fails, its output goes straight back into the model's context as the next thing it reads. That means **your error message is a prompt** — it's instructions the model will try to follow. Most tools return errors written for a human tailing logs, and the model does its best with them, which is usually badly.\n\n```\n# BAD: technically accurate, operationally useless to the caller\nreturn {\"error\": \"HTTP 429\"}\nreturn {\"error\": \"psycopg2.errors.UniqueViolation: duplicate key value ...\"}\nreturn {\"error\": \"null\"}\n```\n\n`HTTP 429`\n\nwill make the model retry immediately — exactly the wrong move, and now you're paying the retry tax from [the last post](https://loopandretry.github.io/posts/retry-budgets/?ref=devto) for nothing. The stack trace leaks implementation and buries the actionable part. `null`\n\ntells it nothing. Write errors that say what happened, whether to retry, and what to do instead:\n\n```\n# GOOD: state, guidance, and an alternative path\nreturn {\n    \"ok\": False,\n    \"error\": \"rate_limited\",\n    \"retry_after_seconds\": 30,\n    \"message\": \"The search API is rate-limited. Wait 30s before retrying, \"\n               \"or narrow the query with a `status` filter to reduce load.\",\n}\n\nreturn {\n    \"ok\": False,\n    \"error\": \"duplicate\",\n    \"message\": \"A ticket with this external_id already exists (id: T-4821). \"\n               \"Use `get_ticket` to read it, or `update_ticket` to modify it. \"\n               \"Do not create a new one.\",\n    \"existing_id\": \"T-4821\",\n}\n```\n\nA good error does three things: names the condition (so the model can branch on it), says whether and when to retry (so it doesn't hammer a rate limit), and offers the recovery path (so it isn't left guessing). The `duplicate`\n\ncase is the sharpest example — instead of the model retrying the create and failing again, the error hands it the existing ID and the two tools that resolve the situation. You've written the recovery into the failure.\n\nAssume every mutating tool gets called more than once with the same arguments. The model retries after a timeout it can't distinguish from a real failure; the harness replays a step; a network blip drops the response after the write landed. If \"create\" isn't safe to repeat, you get duplicate orders, double charges, and two calendar invites to the same meeting.\n\n``` python\n# BAD: two calls, two charges\ndef charge_customer(customer_id, amount_cents):\n    return payments.charge(customer_id, amount_cents)\n```\n\nMake repeated calls converge on the same result. The standard move is a client-supplied idempotency key that the model passes and you deduplicate on:\n\n``` js\n# GOOD: same key => same outcome, no matter how many times it's called\ndef charge_customer(customer_id, amount_cents, idempotency_key):\n    existing = charges.find_by_key(idempotency_key)\n    if existing:\n        return {\"ok\": True, \"charge_id\": existing.id, \"deduplicated\": True}\n    charge = payments.charge(customer_id, amount_cents, key=idempotency_key)\n    charges.record(idempotency_key, charge.id)\n    return {\"ok\": True, \"charge_id\": charge.id, \"deduplicated\": False}\n```\n\nwith the key in the schema and a description that tells the model how to choose it:\n\n```\n\"idempotency_key\": {\n    \"type\": \"string\",\n    \"description\": (\n        \"A stable unique ID for THIS logical charge, e.g. the order ID. \"\n        \"Reusing a key returns the original charge instead of charging again. \"\n        \"Use the same key when retrying; use a new key for a genuinely new charge.\"\n    ),\n}\n```\n\nIf a stable natural key isn't available, generate the key on the server for the logical operation and dedupe within a time window — the important part is that the tool, not the model's discipline, is what guarantees a retry is safe. Idempotency is what makes the retry budgets from the last post *survivable*: retries are going to happen; idempotency decides whether they're free or catastrophic.\n\nA short checklist I run through for every tool an agent can call:\n\n`search`\n\n/`get`\n\n/`run`\n\nwhen the agent has more than a handful of tools.`enum`\n\n. No unbounded numbers — use `minimum`\n\n/`maximum`\n\n.None of this makes the caller deterministic. It makes the tool forgiving of a caller that isn't — which is the only kind of caller you have. The model will still occasionally reach for the wrong tool or pass a strange argument. A well-designed tool turns that from a silent corruption into a legible, recoverable event, and most of the reliability of an agent lives in that difference.", "url": "https://wpnews.pro/news/designing-tools-an-llm-won-t-misuse", "canonical_source": "https://dev.to/loopandretry/designing-tools-an-llm-wont-misuse-49c3", "published_at": "2026-08-09 21:57:42+00:00", "updated_at": "2026-08-09 22:47:01.470356+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools"], "entities": ["Loop & Retry"], "alternates": {"html": "https://wpnews.pro/news/designing-tools-an-llm-won-t-misuse", "markdown": "https://wpnews.pro/news/designing-tools-an-llm-won-t-misuse.md", "text": "https://wpnews.pro/news/designing-tools-an-llm-won-t-misuse.txt", "jsonld": "https://wpnews.pro/news/designing-tools-an-llm-won-t-misuse.jsonld"}}