Letting an LLM call your APIs without losing sleep An engineer detailed the pitfalls of giving large language models access to production APIs, arguing that function calling turns an LLM into an actor that must be constrained like a brilliant but fearless intern. The developer advocates for schema-first, runtime-validated tool definitions, scoping tools to the minimum needed per context, and treating model-generated arguments as untrusted user input. The post emphasizes that business rules belong in validators, not prompts, and that fewer, sharply described tools improve both safety and accuracy. The first time I gave a language model access to a real API, it worked perfectly in the demo. It looked up an order, summarized the status, and everyone in the meeting nodded. Two weeks later, in production, the same setup tried to issue the same refund three times in a row because a timeout made it think the first attempt failed. Nothing was "wrong" with the model. Everything was wrong with how I had wired it up. Function calling or tool calling, same thing is the moment your LLM stops being a text generator and becomes an actor in your system. Most tutorials stop at the happy path: define a function, pass the schema, watch the model call it. That gets you a demo. This article is about everything after the demo. Here is the mental model that changed how I build these systems: designing tools for an LLM is API design for a brilliant intern with no fear. The intern is smart, fast, tireless, and reads documentation more carefully than most senior engineers. The intern also has zero survival instinct. If a button exists, the intern will eventually press it, at 3am, with arguments you never imagined. Every practice below falls out of that one idea. When a model "calls a function", what actually happens is that it emits text that looks like JSON. Your SDK parses it and hands you an object. That object has the epistemic status of user input from a public form, because that is functionally what it is. Models produce arguments that are almost right constantly: a string where you expected a number, an ISO date with the wrong timezone, an enum value that is a plausible synonym of a real one "cancelled" when your API says "canceled" , a negative quantity, an ID copied from the wrong part of the conversation. So the first rule is schema-first, validated at runtime. Define the schema once, derive both the tool definition and the validator from it: js import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; const refundArgs = z.object { orderId: z.string .regex /^ord a-z0-9 {12}$/ , amountCents: z.number .int .positive .max 50 000 , reason: z.enum "damaged", "not delivered", "customer request" , } ; // The same source of truth feeds the model... const refundTool = { name: "issue refund", description: "Issue a partial or full refund for an order.", parameters: zodToJsonSchema refundArgs , }; // ...and guards the execution. function executeRefund rawArgs: unknown { const parsed = refundArgs.safeParse rawArgs ; if parsed.success { return { ok: false as const, reason: parsed.error.message }; } // parsed.data is now actually typed, not just claimed to be return performRefund parsed.data ; } Notice the max 50 000 . Business rules belong in the schema too. The model should be structurally incapable of requesting a $40,000 refund, not merely discouraged by the prompt. Prompts are suggestions; validators are laws. Here is a question I ask on every integration now: what is the minimum set of tools this specific context needs? Most systems dump every tool into every conversation. That is like giving the intern a master keycard on day one. Instead, scope capabilities: get order and issue refund are different risk classes. A conversation that is just answering questions should receive only read tools. The model literally cannot misuse a tool it was never given. customerId , the model can pass the wrong customerId .The counterintuitive part: fewer tools also make the model smarter. Tool selection is a decision the model can get wrong, and every irrelevant tool in the list is a chance to get it wrong. Small, sharply described toolsets improve accuracy and safety at the same time. It is one of the few free lunches in this field. This one cost me real money, so I will be emphatic about it. Most agent frameworks, and most hand-rolled loops, have retry logic somewhere. A thrown exception looks like a transient infrastructure failure, so something retries it: the framework, the queue, your own catch block. Now walk through what happens when a payment API times out. The charge may have succeeded. Your code throws. The retry fires. The customer is charged twice. A thrown error is a promise to your infrastructure that retrying is safe. For non-idempotent operations, that promise is a lie. The fix is to make failure a value, not an exception: type ToolResult