DEV Community

Cover image for Your API Was Built for Humans. Now an AI Agent Is Calling It.
Kainat Saricioglu
Kainat Saricioglu

Posted on

Your API Was Built for Humans. Now an AI Agent Is Calling It.

For years we designed APIs around one assumption: a human is somewhere on the other side of the request. A user clicks a button, the frontend sends a request, the API validates it, the backend does the work, and a response comes back.

Then AI agents showed up. Now the caller might not be a browser. It might be an agent deciding which endpoint to call, what arguments to send, whether to call something else afterwards, and whether the result is good enough to continue.

Your API can still be perfectly correct. Authentication still works, the endpoints still follow REST conventions, and nothing is broken. But an API that is comfortable for humans is not automatically a good API for agents, and the gap shows up in places backend engineers already care about: contracts, permissions, retries, errors, and tracing.

Quick definitions: An AI agent is a program built around a language model that decides which actions to take to complete a task, instead of following a fixed script. A tool is one action you expose to that agent, such as "cancel an order." MCP (Model Context Protocol) is an open standard that describes how an AI client discovers and calls those tools, so every integration doesn't have to invent its own mechanism.

The difference isn't just "who is calling"

In a traditional application, the frontend already knows almost everything. It knows what the endpoint does, which fields are required, which values are valid, and what should happen after a successful response. The API can afford to be terse, because a developer has already read the docs and encoded that knowledge in the client.

flowchart LR
    U([User]) --> FE[Frontend]
    FE -->|POST /orders| API[Backend API]
    API --> DB[(Database)]

An agent starts with far less. Before it can call anything, it has to discover which tools exist, pick one, construct the arguments, interpret the response, and decide what to do next.

flowchart LR
    U([User]) --> AG[AI agent]
    AG --> D[Discover tools]
    D --> S[Choose a tool]
    S --> P[Construct arguments]
    P -->|API call| API[Backend API]
    API --> R[Interpret response]
    R --> N[Decide next step]
    N --> S

The agent isn't just executing a workflow. It's participating in one. So your API now has to communicate more than "here is an endpoint." It has to communicate what can be done, what is allowed, what input is expected, and what the operation actually means.

1. Make operations explicit

Take an endpoint like POST /orders/action. A frontend developer reads the docs once and moves on. An agent has to guess: does "action" mean create, cancel, approve, retry, or update?

An agent-facing interface works better when each operation is named and described on its own:

{
  "name": "cancel_order",
  "description": "Cancel an existing order that has not yet been shipped.",
  "parameters": {
    "orderId": { "type": "string", "description": "The order's identifier." },
    "reason":  { "type": "string", "description": "Why the customer is cancelling." }
  }
}
Enter fullscreen mode Exit fullscreen mode

The description is no longer just documentation. It becomes part of the model's decision-making context, which means vague wording leads directly to wrong tool choices. It's worth describing side effects too: an agent should be able to tell from the description alone that refund_payment moves real money.

2. Responses are part of the conversation

Traditional responses are written for code that already knows the domain:

{ "id": 4821, "status": 3, "code": "PENDING", "x": true }
Enter fullscreen mode Exit fullscreen mode

A frontend developer knows that status 3 means pending and that x is the cancel flag. An agent doesn't, and it will guess. Compare that with a response that carries its own meaning:

{
  "orderId": "4821",
  "status": "pending",
  "canCancel": true,
  "message": "The order has not been shipped and can still be cancelled."
}
Enter fullscreen mode Exit fullscreen mode

This doesn't mean stuffing paragraphs into every payload. It means the contract should expose meaning rather than internal implementation details. A useful test: if you deleted the frontend entirely, could a caller understand this response without guessing? If not, the contract is leaning on knowledge that lives outside the API.

3. Don't turn every API into an AI API

I wouldn't rewrite existing REST APIs just because agents exist. Your system still serves browsers, mobile apps, internal services, scheduled jobs, and partner integrations, and those consumers have different needs.

The more interesting question is what an agent-facing layer looks like on top of what you already have. Often it's a thin adapter that exposes a small set of well-described tools and calls the same services underneath:

flowchart TD
    AG([AI agent]) --> T[Agent tool layer / MCP server]
    FE([Browser / mobile]) --> API[Existing REST API]
    T --> SVC[Application services]
    API --> SVC
    SVC --> DB[(Database)]

The business logic doesn't change. What changes is that one more consumer gets an interface designed for how it actually works. This is also why MCP is interesting: it gives that layer a standard shape for describing and invoking tools.

4. "The agent has my token" is not a security architecture

This is where things get serious. Suppose your API already exposes GET /customers/{id}, POST /payments, POST /refunds, and DELETE /users/{id}. A human user may be allowed to do all of that. Now an agent is doing it on the user's behalf, and the question becomes: who is actually authorized here, and for what?

The easy shortcut is to hand the agent one powerful access token and let it call anything. It works, right up until a prompt injection, a misread instruction, or a wrong tool choice turns into a deleted customer. (Prompt injection is when text the model reads, such as the contents of an uploaded document, contains instructions that hijack what the agent does next.)

The MCP authorization specification is strict about this, and the reasoning applies well beyond MCP. A protected server acts as an OAuth 2.1 resource server, meaning it must validate that a token was issued for itself and reject anything else. Passing a client's token straight through to an upstream API is explicitly forbidden, because the upstream service can no longer tell who is really asking.

So the architecture should not be "user token → agent → whatever API the agent wants." It should have a boundary where tokens are validated and permissions are narrow:

Agent                          Agent
 ├── customer.read     instead   └── admin.*
 ├── order.read          of
 └── order.cancel
Enter fullscreen mode Exit fullscreen mode

Least privilege, meaning each caller gets only the permissions it needs, matters more than ever once software is choosing which operations to invoke. In ASP.NET Core, that boundary is ordinary policy-based authorization applied per tool:

app.MapPost("/agent-tools/cancel-order", async (
        CancelOrderRequest request,
        IOrderService orders,
        CancellationToken ct) =>
    {
        var result = await orders.CancelAsync(request.OrderId, request.Reason, ct);
        return result.ToHttpResult();
    })
    .RequireAuthorization("order.cancel");  // this tool, this scope, nothing wider
Enter fullscreen mode Exit fullscreen mode

5. Reads and writes are not the same kind of tool

get_customer and delete_customer are both "tools," but their consequences aren't remotely comparable. It helps to separate them deliberately: reads such as get_order or search_products on one side, writes such as refund_payment or delete_customer on the other.

For consequential writes, the agent should confirm with the person before acting: "I found the order and it can still be cancelled. Would you like me to cancel it?" Only after a yes does cancel_order run.

The key distinction is that authorization and confirmation are different things. Having permission to perform an operation doesn't mean the operation should happen without asking, especially when it's irreversible or moves money.

6. Idempotency stops being optional

Agents retry. Networks fail. Tools time out. And an agent often cannot tell whether an operation actually succeeded.

Picture a payment that succeeds on the server, followed by a network timeout on the way back. All the agent sees is a timeout, so it tries again, and now the customer has paid twice.

Idempotency means an operation can be repeated without changing the result beyond the first time. The usual mechanism is an idempotency key: the caller sends a unique value with the request, and the server remembers the outcome for that key.

POST /agent-tools/create-payment
Idempotency-Key: 9c4d7e2a-1f0b-4c88-9a41-2f6e0c3d5b7a
Enter fullscreen mode Exit fullscreen mode

On the server, the first request does the work and stores its result; a repeat of the same key returns the stored result instead of charging again:

public async Task<PaymentResult> CreatePaymentAsync(
    string idempotencyKey, PaymentRequest request, CancellationToken ct)
{
    var existing = await store.FindByKeyAsync(idempotencyKey, ct);
    if (existing is not null)
        return existing.Result;          // already processed: return, don't re-charge

    var result = await payments.ChargeAsync(request, ct);

    await store.SaveAsync(idempotencyKey, result, ct);
    return result;
}
Enter fullscreen mode Exit fullscreen mode

In production you'd also handle two requests arriving with the same key at once, usually by storing the key first with a unique constraint so the second caller waits or gets the first one's result. The point is that the agent doesn't need to understand any of this. The API protects itself against repeated execution.

7. Errors should tell the caller what to do next

400 Bad Request is technically valid and practically useless to an agent. It can't tell whether to fix the arguments, retry later, or stop and ask the user. So it may well do the worst thing: retry the same call in a loop.

An error that carries a decision inside it works much better:

{
  "error": "ORDER_CANNOT_BE_CANCELLED",
  "message": "Order 4821 has already been shipped.",
  "retryable": false,
  "nextAction": "Offer the customer a return instead."
}
Enter fullscreen mode Exit fullscreen mode

Now the caller knows what happened, whether retrying is pointless, and what a sensible next step looks like. This is one of those changes that helps humans just as much, which is a good sign you're improving the API rather than decorating it for AI.

8. Tracing has to cover the whole workflow

Classic request logging tells you that POST /orders returned 200 in 183 ms. That's no longer the interesting unit of work. A single user request might look like this:

user request → model call → search_customers → get_customer
             → search_orders → cancel_order → model call → answer
Enter fullscreen mode Exit fullscreen mode

If that takes 40 seconds, where did the time go? The model, a slow endpoint, a retry, or a loop where the agent kept calling the same tool? You can't tell from per-request logs, which is why distributed tracing, meaning one connected trace that follows a request across every service and call it touches, becomes the thing that saves you.

OpenTelemetry's GenAI semantic conventions (a shared vocabulary for naming these spans) define spans for agent runs, model calls, and tool executions, along with attributes for the model and token usage. They're still evolving, so names may change, but the shape is already useful. In .NET, a tool span is just an Activity:

private static readonly ActivitySource Source = new("MyApp.AgentTools");

using var activity = Source.StartActivity("execute_tool", ActivityKind.Internal);
activity?.SetTag("gen_ai.tool.name", "cancel_order");
activity?.SetTag("app.order.id", request.OrderId);
Enter fullscreen mode Exit fullscreen mode

With that in place, you can follow one user request from the agent's decision, through the tool call, into the API and database, and back out again.

The checklist

If I were designing an agent-facing interface today, this is what I'd hold it to:

  1. Explicit operations. cancel_order, not POST /orders/action.
  2. Described side effects. The agent should know a tool moves money before it calls it.
  3. Narrow permissions. Per-tool scopes, never a shared admin token.
  4. Safe retries. Idempotency keys on every state-changing operation.
  5. Meaningful responses. No decoding of internal status codes.
  6. Actionable errors. Say whether a retry helps, and what to try instead.
  7. End-to-end traces. Cover the agent run, not just the HTTP request.
  8. Human confirmation for anything irreversible or financially significant.

What this means for backend engineers

I don't think agents make REST APIs go away. What's changed is that APIs have a new kind of consumer, one that reads descriptions, picks tools, builds parameters, reacts to errors, retries, and chains operations together.

That pulls API design into AI system design. And the skills it needs are ones backend engineers already have: clear contracts, least privilege, idempotency, useful errors, and good tracing. We were supposed to be doing all of this anyway. The difference is that a human developer could paper over a vague contract by reading the code, and an agent can't.

The endpoint hasn't changed. The caller has.

Have you exposed part of your system to an AI agent yet? I'd be interested to hear what you had to change first. Let me know in the comments. 👇

Top comments (1)

Collapse
 
raju_dandigam profile image
Raju Dandigam

The distinction between authorization and confirmation is especially important for cancel_order and payment tools. I'd add one more contract rule to your idempotency section: bind the key to the operation and normalized arguments, so a retry with the same key but a different amount or order ID fails visibly instead of returning an unrelated prior result. Have you found a clean way to expose that mismatch back to the agent as a non-retryable error?