Your AI agent is only as good as the API it calls.
I wired an agent to a NestJS backend last quarter. It had to create orders, check stock, and issue refunds. The agent failed constantly. Not because the model was dumb. Because the endpoints were built for a frontend, not for a machine: five calls to do one job, huge payloads, vague errors.
That mismatch is easy to miss, because the API works fine when a human drives it through a user interface. A human sees a screen, fills in what is missing, and recovers from a vague error without thinking. An agent cannot do any of that. It reads what you return, decides what to do next from that text alone, and acts on it. Every gap in your API becomes a decision the model has to guess at, and guesses are where bad database writes come from.
The wrong approach
The first wrong approach is to expose your REST endpoints directly as agent tools. A frontend endpoint is designed to serve a screen. It returns everything that screen might need, paginates in a way that suits scrolling, and assumes the caller already knows the flow. Hand that to an agent and it will fetch far more data than it needs, miss the one field that matters, and chain calls in the wrong order, because nothing in the response told it the right order.
The second wrong approach is worse: build one giant do everything endpoint and let the model figure it out. That looks flexible. In practice it gives the agent too much room to improvise, and improvisation is where hallucinations turn into real database writes. A tool that can do anything cannot be tested properly, cannot be logged properly, and cannot be reasoned about when something goes wrong. When a refund is issued by mistake, you want to know exactly which tool did it and exactly what it received. A giant endpoint cannot tell you that.
How I structure it now: a thin tool layer
I keep a thin tool layer on top of the real services.
Each tool does exactly one thing, takes typed inputs, and returns a small predictable shape. The business logic stays in the service. The tool is just a translator between the agent and the service, nothing more.
Note: the snippet below is simplified to show the structure. The Tool and Param decorators come from your agent framework, not from NestJS itself, so adapt them to the framework you use.
// tools/order.tools.ts
import { OrdersService } from "./orders.service";
export interface OrderItem {
productId: string;
quantity: number;
}
export class OrderTools {
constructor(private readonly orders: OrdersService) {}
@Tool({ name: "create_order", description: "Create an order for a customer" })
async createOrder(
@Param("customerId") customerId: string,
@Param("items") items: OrderItem[],
) {
const order = await this.orders.create({ customerId, items });
return {
orderId: order.id,
status: order.status,
total: order.total,
};
}
@Tool({ name: "check_stock", description: "Check stock for one product" })
async checkStock(@Param("productId") productId: string) {
const stock = await this.orders.checkStock(productId);
return { productId, inStock: stock.quantity > 0, quantity: stock.quantity };
}
@Tool({ name: "issue_refund", description: "Issue a refund for a paid order" })
async issueRefund(
@Param("orderId") orderId: string,
@Param("reason") reason: string,
) {
const refund = await this.orders.refund(orderId, reason);
return { orderId, refundId: refund.id, status: refund.status };
}
}
Three things matter in that code, and none of them is the decorator syntax.
First, the service owns the logic. Creating an order, checking stock, issuing a refund: all the validation, all the rules about what can be refunded and when, all of it lives in OrdersService, exactly where it lived before the agent arrived. The tool adds no rules of its own. If the service changes, the tool still works, because the tool only passes inputs through and shapes the output.
Second, the return shape is small and stable. The agent does not get the full order object with every relation attached. It gets an id, a status, and a total. That is enough for it to continue the conversation and to tell the user what happened. Small outputs also keep the model focused: a huge payload is not just wasteful, it is a distraction that makes the next decision worse.
Third, the agent never touches the service directly. It only sees small honest tools. That boundary is what makes the rest of the system manageable.
Errors must be part of the design
An agent treats an error message as information, so a vague error is worse than no error at all. "Something went wrong" tells the model nothing, so it retries, or tries a different tool, or invents a workaround. A structured error tells it what actually happened and what it can do about it.
Return errors in the same shape every time: a short code the agent can match on, and a plain message a human can read. Keep the codes small in number. A handful of codes like not found, out of stock, already refunded, and invalid input covers almost everything an order flow needs. If the agent sees "already refunded", it stops instead of refunding twice. If it sees "out of stock", it can tell the user honestly instead of placing an order that will fail later.
Never leak internals in an error. Stack traces, database messages, and internal service names are not information for the model, they are noise that it will sometimes repeat to the user. Log those on the server, where they belong, and return only the code and the message.
Log every tool call like any other API call
Every tool call is logged like any other API call: which tool ran, what inputs it received, what it returned, and whether it succeeded. That log is what turns a strange outcome into a debuggable one. When a user says the agent refunded the wrong order, you do not have to guess what the model did. You read the log, you see the tool, the order id, and the reason it was given, and you know exactly where the chain broke.
That log is also your safety net for destructive tools. A tool that only reads data is low risk. A tool that writes data, moves money, or changes state deserves more care. In production I keep write tools behind the same checks any write endpoint would have: authentication, permission checks, and validation in the service. For the most sensitive actions, like issuing a refund, the tool should require an explicit confirmation from the user before it runs, not just a confident model. The model proposes, the human confirms, the tool executes. That one rule removes a whole class of expensive mistakes.
Test the tool, not just the service
A tool layer is easy to test, and that is one of its best arguments. The service already has its own tests. The tool needs tests of its own, for a different question: does this tool accept what the agent will actually send, and does it return the shape the agent expects?
Write a test that calls the tool with realistic inputs, including messy ones: a missing field, an empty items list, a product id that does not exist. Assert the output shape, not just that it did not throw. If the output shape drifts, the agent's next step drifts with it, so the shape is the contract. Testing it is cheap, and it catches the regressions that are hardest to spot by reading code, like a renamed field that silently becomes undefined in the agent's view.
What I would not do
I would not let the model write queries, call a database directly, or reach an endpoint that was built for an admin screen. Every one of those shortcuts removes the layer that keeps the agent honest. The service layer exists for a reason: it enforces the rules, it guards the data, and it keeps one version of the truth. The moment the agent bypasses it, you have two versions of your business logic, and the untested one is the one making writes.
I would also not return full objects "just in case" the agent needs a field later. If it needs that field, add it to the tool output deliberately, with a name that means something. A tool output is an interface, and interfaces grow by decision, not by accident.
The honest summary
Agents do not need a new backend. They need a backend that speaks in small, honest, predictable steps. One tool, one job, typed inputs, a small output, errors the model can act on, and a log for every call. That is not a lot of code. In the project I described, the tool layer was a fraction of the size of the services underneath it, and it was the part that made the agent reliable.
The model gets the credit when an agent works well, but most of the time the difference between a flaky agent and a dependable one is not the model at all. It is the API behind it, and whether that API was built for a machine that reads text and acts on it, or for a screen that a human was always going to interpret.
Do you expose your APIs to agents directly, or do you keep a separate tool layer?
I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. Tech Lead at LoopxCorp. I write about backend engineering, AI integration and practical system design.
Top comments (10)
The 'thin tool layer' pattern is exactly what I've been converging on, but I'm curious: how do you handle tools that need to compose multiple services? For example, create_order might need to check inventory, apply a discount, and reserve stock. Do you keep that orchestration in the service, or do you expose multiple tools and let the agent chain them?
I keep that orchestration in the service, not in the agent. A create_order tool that checks inventory, applies the discount and reserves stock in one place gives the agent a single clear action and keeps the ordering and the failure handling in code I can test. Exposing each step as its own tool looks flexible but it hands the agent the job of getting the sequence right every time, and a missed step becomes a partial order. Where do you draw the line in your own tools, one tool per business action or one per service call?
One tool per business action, almost always. The agent should be thinking in terms of 'create an order' or 'issue a refund,' not 'check inventory, then apply discount, then reserve stock.' If the sequence is fixed and the failure modes are known, it belongs in the service. The agent shouldn't be a workflow engine.
That framing nails it, thinking in business actions rather than steps is exactly the line I use. The test I keep coming back to is whether the sequence can change at runtime: if it is fixed, it lives in the service, and the agent only sees the outcome it needs to act on. Have you found a case where a sequence looked fixed but still had to move into the agent?
Yes, one case: a 'create user' flow that looked fixed — validate email, create account, send welcome email. But it turned out that some enterprise customers needed a manual approval step before the account was created, and the approval could take days. That's not a fixed sequence anymore; it's a state machine with a human gate. We moved the orchestration into the service, but the agent needed a separate 'check approval status' tool to decide when to proceed.
That is a great example of the line moving in practice. Once approval can take days, the flow is no longer a sequence to run through, it is a state to watch, and keeping the orchestration in the service while giving the agent a status check tool keeps both sides honest. The detail I would add is to make that status tool return the reason a request is still waiting, not just a yes or no, so the agent can tell the user something useful instead of just polling again. Have you found a clean way to surface that waiting reason without leaking internal approval notes?
The cleanest way I've found is to return a small, fixed set of reason codes rather than free text. Something like pending_approval, pending_review, waiting_on_customer, blocked_by_policy. The agent can map those to user-friendly messages, and the internal notes stay in the service. It's not as rich as free text, but it's safe.
Fixed reason codes are the right trade, and your list covers the cases that actually matter. Free text feels richer until one approver writes something the agent repeats to a customer, so keeping the notes in the service and the codes on the wire is the safer shape. The one thing I would add is a stable code for the case where the wait is on our side and not the customer's, so the agent never blames the wrong party. Do you version those codes when a new waiting reason shows up, or just extend the set?
Spot on. Your point about the agent blaming the wrong party is the exact failure mode that keeps me up at night. If the API just returns a generic "PENDING" or "DELAYED", the LLM will confidently invent a reason, and it usually defaults to blaming the user for not providing something. A specific WAIT_INTERNAL_REVIEW code forces the agent to own the delay.
To answer your question: I strictly extend the set, but I namespace them. We use prefixes like CUST_ (customer fault) and SYS_ (system fault), followed by the reason. So SYS_KYC_REVIEW is a completely different branch in the agent's prompt logic than CUST_MISSING_DOCS. It keeps the payload flat and the codes immutable — you never version a code (which breaks deterministic parsing), you just add a new string to the enum.
Free text has its place in the notes field for human auditors, but keeping the actual wire protocol strictly typed is the only way to keep the agent honest.
That namespacing is the detail that makes the extend only rule work in practice, because the prefix tells the agent which branch it is in before it reads the reason at all. I would add the same discipline to the prompt side too, teaching the agent that an unknown code means ask for clarification rather than guessing a reason. Have you ever had an old code outlive its meaning and need retiring without breaking the parsing?