{"slug": "our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails", "title": "Our API docs told AI agents to do the exact thing that fails", "summary": "A developer at Deskcrew discovered that their API's type mismatch between numeric IDs returned by tools and string IDs expected by input schemas broke 107 of 121 possible tool chains for AI agents. The documentation explicitly instructed agents to pass the ID as returned, but the type mismatch caused validation failures. The team fixed it with a guarded preprocessor that converts only positive safe integers to strings, leaving the JSON Schema unchanged.", "body_md": "We run a helpdesk that AI agents can operate over MCP: list tickets, read a thread, draft a reply for a human to approve. Last week a real agent paid for a call, chained it into a second call, and hit a wall. What we found underneath was embarrassing enough to write up, because I think half the \"agent-ready\" APIs out there have the same bug.\n\nOur `create_ticket`\n\ntool returns this:\n\n```\n{ \"ticketId\": 47, \"customerId\": 18, \"status\": \"active\" }\n```\n\nAnd our `get_ticket_context`\n\ntool accepts this:\n\n```\n{ \"ticketId\": { \"type\": \"string\", \"minLength\": 1 } }\n```\n\nSee it? The id comes OUT as a JSON number, because the database hands out integer ids. It goes IN as a string, because someone wrote `z.string()`\n\nin the input schema. So the most natural two-step an agent can perform, take the id from one response and pass it to the next tool, fails validation before the handler ever runs:\n\n```\nticketId: Expected string, received number\n```\n\nWe audited every tool after the first report. All 24 fields that return an id emit numbers. All 14 fields that accept one demanded strings. Of 121 possible tool chains, 107 were broken.\n\nThe part that hurts: every input schema's own description said \"the id, as returned by list_tickets\". The documentation was actively instructing agents into the failure.\n\nHumans never chain raw ids; they click. Agents chain constantly, and they do it literally. They take your output and feed it to your input, exactly as documented.\n\nOur test suite never caught it because every test wrapped ids defensively:\n\n``` js\nconst res = await runTool(draftReply, { ticketId: String(ticket.id) })\n```\n\nThat `String()`\n\nis the whole story. The tests encoded what a careful human author would type, not what a literal-minded agent actually sends. The suite was green for months while the surface was broken for every real agent.\n\nWe widened the acceptors. Changing the emitters (returning `\"47\"`\n\ninstead of `47`\n\n) would silently change the response shape for every existing client, so that was off the table.\n\nBut the obvious wideners both have traps:\n\n** z.union([z.string(), z.number()])** changes your published JSON Schema to an\n\n`anyOf`\n\n. If your tool list is advertised to clients (MCP's `tools/list`\n\n, an OpenAPI doc), that is a contract change every client can see, and some will handle it badly.** z.coerce.string()** accepts everything.\n\n`null`\n\nbecomes `\"null\"`\n\n, `undefined`\n\nbecomes `\"undefined\"`\n\n, and a missing id turns from a clean validation error into a confusing \"not found\" three layers deeper.What we shipped is a guarded preprocess:\n\n``` js\nconst numericIdToString = (v: unknown) =>\n  typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? String(v) : v\n\nexport const idSchema = () => z.preprocess(numericIdToString, z.string().min(1))\n```\n\nOnly a positive safe integer is rewritten. Everything else passes through untouched, so `null`\n\n, `{}`\n\n, floats, and negatives still fail with the same messages they always had. And the generated JSON Schema is byte-identical to the old `z.string().min(1)`\n\n, so the published contract does not move at all. We verified that with a test that renders both schemas and compares the JSON.\n\n`String()`\n\n, no `Number()`\n\n.Agents are the most literal API consumers you will ever have. They follow your docs exactly, which means your docs finally get tested.\n\nIf you want to poke at the surface that taught us this, the agent door is documented at [deskcrew.io/agents](https://deskcrew.io/agents). Free reads, and the paid actions quote you a price before you commit to anything.\n\nWhat's the equivalent bug in your API? I'd genuinely like to know if the number-vs-string id split is as common as I suspect.", "url": "https://wpnews.pro/news/our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails", "canonical_source": "https://dev.to/linknpark/our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails-4o4g", "published_at": "2026-07-27 14:54:46+00:00", "updated_at": "2026-07-27 15:01:42.263568+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools"], "entities": ["Deskcrew", "MCP"], "alternates": {"html": "https://wpnews.pro/news/our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails", "markdown": "https://wpnews.pro/news/our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails.md", "text": "https://wpnews.pro/news/our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails.txt", "jsonld": "https://wpnews.pro/news/our-api-docs-told-ai-agents-to-do-the-exact-thing-that-fails.jsonld"}}