Designing APIs for AI Agent Consumption: Structured Actions, Self-Describing Responses, and Tool-Friendly Patterns in TypeScript
AI agents are now first-class API consumers. This guide covers the patterns that make APIs consumable by LLM-powered agents: structured action endpoints, self-describing responses, deterministic pagination, error messages that enable self-correction, and production considerations for rate limiting, auth, and observability when agents are your primary API consumer.
Until recently, “API design” meant designing for two audiences: internal services and human developers. Both tolerate ambiguity reasonably well. Developers read documentation when something fails. Internal services are owned by the same team and can be updated when a contract changes.
AI agents are different. They are LLM-powered processes that parse your response schema, make decisions based on what they read, retry on error, and chain your endpoint into a larger workflow they are constructing at runtime. They do not read docs. They cannot call you on Slack. They will interpret your 200 response with an error string inside as a success.
If you are building an API that agents will call (and most APIs built in 2026 will end up in at least one agent’s tool list), the design decisions that were once low-stakes now determine whether the agent works reliably or hallucinates its way through your system.
This article covers the concrete patterns that make the difference.
Why Agents Break on APIs Designed for Humans
Before getting into solutions, it is worth naming the specific failure modes.
Implicit error states. Human developers learn quickly that your API returns 200 with { "success": false } when validation fails. Agents do not. They see a 200 and proceed, often with bad data.
Unstructured error messages. A message like "Invalid input: check your request" gives a human a starting point to debug. An agent needs to know exactly which field failed, what was expected, and what value was received. Without that, it cannot self-correct.
Ambiguous action semantics. A POST /items that sometimes creates and sometimes updates depending on whether an ID is included is fine for humans who know the convention. An agent selecting that tool has no way to know the behavior without reliable, machine-readable documentation attached to the endpoint definition.
Non-deterministic pagination. Offset-based pagination with mutable data means an agent iterating through pages may miss records or see duplicates. It has no way to detect this.
No execution context. When an agent is coordinating multiple API calls across a workflow, there is no way for your API to know which calls came from the same agent run, which makes observability and debugging impossible.
Structuring Endpoints as Explicit Actions
The most important shift when designing for agents is moving from resource-oriented to action-oriented design. This does not mean abandoning REST, but it means making action semantics unambiguous.
Instead of:
POST /orders # create or update?
PUT /orders/:id # full replace or partial?
Use:
POST /orders/create
POST /orders/:id/cancel
POST /orders/:id/fulfill
POST /orders/items/add
The agent selecting a tool should be able to infer the effect from the name alone without reading a body schema. This is especially important because most agent frameworks select tools based on name and description, then fill the parameters separately.
Here is a Hono implementation that makes this pattern explicit:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono();
const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
lineItems: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})
).min(1),
currencyCode: z.string().length(3),
});
const CancelOrderSchema = z.object({
reason: z.enum(["customer_request", "fraud", "inventory_unavailable", "payment_failed"]),
notifyCustomer: z.boolean().default(true),
});
// Action: create a new order
app.post(
"/orders/create",
zValidator("json", CreateOrderSchema),
async (c) => {
const body = c.req.valid("json");
const order = await createOrder(body);
return c.json({
success: true,
data: { orderId: order.id, status: order.status },
links: {
self: `/orders/${order.id}`,
cancel: `/orders/${order.id}/cancel`,
fulfill: `/orders/${order.id}/fulfill`,
},
}, 201);
}
);
// Action: cancel an existing order
app.post(
"/orders/:orderId/cancel",
zValidator("json", CancelOrderSchema),
async (c) => {
const { orderId } = c.req.param();
const body = c.req.valid("json");
const order = await cancelOrder(orderId, body);
return c.json({
success: true,
data: { orderId: order.id, status: order.status, cancelledAt: order.cancelledAt },
});
}
);
Notice a few things:
- Every successful response has
success: true. Every error response hassuccess: false. The agent never has to inspect status codes alone. - The response includes
linksthat describe what actions are available next. This lets an agent decide its next step without hard-coding URLs. - The cancel reason is an enum, not a free string. The agent can enumerate valid values from the schema; it does not need to guess.
Self-Describing Responses
Agents use your response data as context for subsequent reasoning. A response that includes only IDs requires the agent to make another API call to look up what those IDs mean. That adds latency, adds cost, and introduces another failure point.
Self-describing responses include enough context that an agent can make a decision without an additional round trip:
// Thin response: forces agent to make more calls
{
"orderId": "ord_abc123",
"status": "fulfilled",
"customerId": "cus_xyz456"
}
// Self-describing response: agent can reason on this directly
{
"success": true,
"data": {
"orderId": "ord_abc123",
"status": "fulfilled",
"statusLabel": "Order shipped and delivered",
"customer": {
"id": "cus_xyz456",
"name": "Acme Corp",
"email": "billing@acmecorp.com"
},
"lineItems": [
{
"productId": "prod_111",
"productName": "Enterprise License",
"quantity": 1,
"unitPrice": 4999,
"currency": "USD"
}
],
"totalAmount": 4999,
"currency": "USD",
"fulfilledAt": "2026-05-09T14:32:00Z"
},
"links": {
"self": "/orders/ord_abc123",
"invoice": "/orders/ord_abc123/invoice",
"refund": "/orders/ord_abc123/refund"
},
"meta": {
"requestId": "req_789",
"processedAt": "2026-05-09T14:32:01Z"
}
}
The statusLabel field is worth highlighting. Agents tend to perform better when they have natural-language descriptions alongside machine-readable codes. The links section uses HATEOAS conventions to tell the agent what it can do next without requiring it to know URL patterns. The meta.requestId gives you a trace handle when the agent reports an error.
Error Messages That Enable Self-Correction
This is the highest-leverage design decision for agent-facing APIs. When an agent calls an endpoint with a bad argument, the error message is its only input for deciding how to retry. If the message is vague, the agent will either retry with the same bad input, generate a hallucinated fix, or give up.
A good agent-facing error response has three parts: what failed, why it failed, and what a valid value looks like.
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { ZodError } from "zod";
function formatZodErrors(error: ZodError) {
return error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
received: "received" in err ? err.received : undefined,
expected: "expected" in err ? err.expected : undefined,
}));
}
app.onError((err, c) => {
if (err instanceof ZodError) {
return c.json(
{
success: false,
error: {
code: "VALIDATION_ERROR",
message: "One or more fields failed validation. See `fieldErrors` for details.",
fieldErrors: formatZodErrors(err),
hint: "Retry with corrected field values. Do not retry with the same payload.",
},
},
400
);
}
if (err instanceof NotFoundError) {
return c.json(
{
success: false,
error: {
code: "NOT_FOUND",
message: `${err.resourceType} with id "${err.resourceId}" does not exist.`,
hint: "Verify the ID before retrying. Do not create a duplicate resource.",
},
},
404
);
}
if (err instanceof ConflictError) {
return c.json(
{
success: false,
error: {
code: "CONFLICT",
message: err.message,
existingResourceId: err.existingId,
hint: "A resource with this identifier already exists. Use the existing resource or supply a unique identifier.",
},
},
409
);
}
return c.json(
{
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred.",
requestId: c.get("requestId"),
hint: "Retry once after 5 seconds. If the error persists, halt and report the requestId.",
},
},
500
);
});
The hint field is a direct instruction to the agent. Write it as if you are writing a note to someone who will read it once and act on it immediately. Avoid passive constructions. "Do not retry with the same payload" is better than "Retrying may not resolve this issue".
The existingResourceId on the conflict error is important: an agent trying to create a resource that already exists can now pivot to using the existing one instead of entering a retry loop or creating duplicates.
Deterministic Pagination for Agent Iteration
Agents frequently need to iterate over full result sets to perform analysis, aggregation, or to find a specific item. Offset-based pagination has two problems in this context: it breaks when underlying data changes during iteration, and the agent must track page numbers and calculate offsets manually.
Cursor-based pagination is better. Keyset pagination is the most reliable form of it:
import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
const ListOrdersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
after: z.string().optional(),
status: z.enum(["pending", "fulfilled", "cancelled"]).optional(),
});
app.get(
"/orders",
zValidator("query", ListOrdersQuerySchema),
async (c) => {
const { limit, after, status } = c.req.valid("query");
// Decode the cursor: it encodes (createdAt, orderId) for stable ordering
let cursorCondition: { createdAt: Date; id: string } | null = null;
if (after) {
const decoded = JSON.parse(Buffer.from(after, "base64url").toString("utf8"));
cursorCondition = { createdAt: new Date(decoded.createdAt), id: decoded.id };
}
const orders = await db.order.findMany({
where: {
...(status ? { status } : {}),
...(cursorCondition
? {
OR: [
{ createdAt: { lt: cursorCondition.createdAt } },
{
createdAt: { equals: cursorCondition.createdAt },
id: { gt: cursorCondition.id },
},
],
}
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: limit + 1,
});
const hasMore = orders.length > limit;
const items = hasMore ? orders.slice(0, limit) : orders;
const nextCursor = hasMore
? Buffer.from(
JSON.stringify({
createdAt: items[items.length - 1].createdAt.toISOString(),
id: items[items.length - 1].id,
})
).toString("base64url")
: null;
return c.json({
success: true,
data: items,
pagination: {
limit,
hasMore,
nextCursor,
// Tell the agent exactly how to get the next page
nextPage: nextCursor ? `/orders?after=${nextCursor}&limit=${limit}${status ? `&status=${status}` : ""}` : null,
totalReturned: items.length,
},
});
}
);
Key details:
nextPagegives the agent a complete URL to call next, without requiring it to construct query strings. This reduces hallucination risk.hasMore: falsewithnextCursor: nullis an unambiguous signal that iteration is complete. Agents do not need to infer completion from an empty results array.- The cursor encodes a composite key
(createdAt, id)to handle ties, making the ordering stable even if records are inserted during iteration.
Agent Identity and Authentication
Agents need credentials like any other caller, but their credential management has different constraints. An agent running inside a CI pipeline, a Zapier workflow, or a LangGraph orchestrator cannot do interactive OAuth flows. It needs long-lived, revocable tokens with explicit scope boundaries.
The patterns from B2B API authentication apply directly here, with one addition: you need a way to know that the caller is an agent, not a human, so you can apply different rate limits and log differently.
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
type AgentContext = {
Variables: {
callerId: string;
callerType: "human" | "agent";
agentId: string | null;
scopes: string[];
requestId: string;
};
};
const authenticateAgent = createMiddleware<AgentContext>(async (c, next) => {
const authHeader = c.req.header("Authorization");
const agentHeader = c.req.header("X-Agent-Id");
const requestId = c.req.header("X-Request-Id") ?? crypto.randomUUID();
c.set("requestId", requestId);
if (!authHeader?.startsWith("Bearer ")) {
return c.json(
{
success: false,
error: {
code: "UNAUTHORIZED",
message: "Authorization header with Bearer token is required.",
hint: "Include an Authorization: Bearer <token> header on every request.",
},
},
401
);
}
const token = authHeader.slice(7);
const apiKey = await db.apiKey.findUnique({
where: { keyHash: hashToken(token) },
select: { id: true, scopes: true, agentId: true, revokedAt: true },
});
if (!apiKey || apiKey.revokedAt) {
return c.json(
{
success: false,
error: {
code: "UNAUTHORIZED",
message: "API key is invalid or has been revoked.",
hint: "Re-authenticate with valid credentials. Do not retry with the same token.",
},
},
401
);
}
c.set("callerId", apiKey.id);
c.set("callerType", agentHeader ? "agent" : "human");
c.set("agentId", agentHeader ?? null);
c.set("scopes", apiKey.scopes);
await next();
});
The X-Agent-Id header is a convention: agent runtimes (LangGraph, AutoGen, custom orchestrators) should stamp every request with an identifier for the agent run or session. This is not a security mechanism; it is an observability mechanism. When an agent goes off the rails and makes 400 requests in 10 seconds, you need to be able to trace those requests back to a specific agent instance.
Rate Limiting for Agents
Agents do not feel bad about hitting rate limits. A human slows down when they see a 429. An agent retries immediately if you let it.
Two patterns matter here:
Exponential backoff headers. Tell the agent exactly how long to wait, not just that it should wait:
const agentRateLimiter = createMiddleware<AgentContext>(async (c, next) => {
const callerType = c.get("callerType");
const callerId = c.get("callerId");
// Agents get a separate bucket with different limits
const bucketKey = callerType === "agent"
? `rl:agent:${c.get("agentId") ?? callerId}`
: `rl:human:${callerId}`;
const limit = callerType === "agent" ? 60 : 300; // requests per minute
const result = await redisRateLimit(bucketKey, limit, 60);
c.header("X-RateLimit-Limit", String(limit));
c.header("X-RateLimit-Remaining", String(result.remaining));
c.header("X-RateLimit-Reset", String(result.resetAt));
if (!result.allowed) {
const retryAfterSeconds = Math.ceil((result.resetAt - Date.now()) / 1000);
c.header("Retry-After", String(retryAfterSeconds));
return c.json(
{
success: false,
error: {
code: "RATE_LIMITED",
message: `Rate limit exceeded. Limit: ${limit} requests/minute.`,
retryAfterSeconds,
hint: `Wait ${retryAfterSeconds} seconds before retrying. Do not retry sooner.`,
},
},
429
);
}
await next();
});
The hint field carries the instruction the agent will act on. "Do not retry sooner" is a direct instruction. Most LLMs will follow it.
Per-agent-session limits. If you know an agent is running a specific task (identified by X-Agent-Id), you can apply a budget across the entire session rather than per-minute windows. This prevents a runaway agent from burning through your rate limit on a single misguided workflow.
Observability When Agents Are the Callers
When a human makes a bad request, they notice it immediately. When an agent makes a bad request, it might silently retry 50 times before the timeout, or worse, succeed by hallucinating a workaround.
Your logging needs to capture enough context to reconstruct what the agent was doing:
const observabilityMiddleware = createMiddleware<AgentContext>(async (c, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
const agentId = c.get("agentId");
const callerType = c.get("callerType");
await logger.info("api_request", {
requestId: c.get("requestId"),
method: c.req.method,
path: c.req.path,
status: c.res.status,
durationMs: duration,
callerId: c.get("callerId"),
callerType,
agentId,
// If the agent passed a task identifier, log it
agentTaskId: c.req.header("X-Agent-Task-Id"),
// Log 4xx and 5xx response bodies for debugging agent failures
responseBody: c.res.status >= 400 ? await c.res.clone().json() : undefined,
});
// Alert on patterns that indicate agent misbehavior
if (agentId && c.res.status === 400) {
await metricsClient.increment("agent.validation_error", {
agentId,
endpoint: c.req.path,
});
}
});
The X-Agent-Task-Id header is another optional convention: the agent runtime stamps the ID of the task it is currently executing. When you see 40 consecutive 400 errors all carrying the same task ID, you know exactly what the agent was trying to do and where it got stuck.
API-First vs MCP vs Custom Integration
When you are exposing functionality to agents, you have three structural options.
API-first (REST or RPC): Your existing HTTP API, hardened with the patterns above. Works with any agent framework. No additional infrastructure. The agent framework’s tool-calling layer handles the HTTP client.
Model Context Protocol (MCP): A structured protocol for exposing tools and resources to LLM agents. You define tools with JSON Schema, and the agent runtime calls them through the MCP interface. Growing ecosystem, strong Anthropic backing, increasingly standard for new agent integrations.
Custom integration: Language-specific SDK (Python, TypeScript) that the agent imports and calls directly. Maximum type safety and control, but tight coupling to a specific agent framework and runtime.
| Dimension | API-First (REST) | MCP | Custom SDK |
|---|---|---|---|
| Agent framework compatibility | Universal | MCP-compatible clients only | SDK language only |
| Tool discovery | Manual (docs, OpenAPI spec) | Built-in (tool manifest) | Manual or type-based |
| Schema enforcement | Per-endpoint validators | Protocol-level | Language type system |
| Versioning | HTTP + URL strategy | Tool version in manifest | Package version |
| Auth model | API keys, OAuth, JWT | Delegated to transport | Embedded in SDK |
| Observability | Standard HTTP logs | MCP session logs | Custom instrumentation |
| Setup cost | Low (existing API) | Medium (MCP server) | High (SDK build and publish) |
| Best for | Existing APIs, broad compat | New agent-first products | Single-framework deployments |
If you are hardening an existing API for agent consumption, start with API-first and apply the patterns in this article. If you are building a new product where AI agents are the primary users, MCP is worth the additional setup cost because it gives you tool discovery, schema validation, and session management without building them yourself.
Production Considerations
Idempotency keys are mandatory. Agents retry on transient errors. Without idempotency keys, a retry after a network timeout can create duplicate resources. Require idempotency keys on every state-mutating endpoint and cache the result for at least 24 hours:
const idempotencyMiddleware = createMiddleware(async (c, next) => {
const idempotencyKey = c.req.header("Idempotency-Key");
if (c.req.method !== "GET" && !idempotencyKey) {
return c.json(
{
success: false,
error: {
code: "MISSING_IDEMPOTENCY_KEY",
message: "Idempotency-Key header is required for mutating requests.",
hint: "Generate a UUID and include it as the Idempotency-Key header. Reuse the same key when retrying the same operation.",
},
},
400
);
}
if (idempotencyKey) {
const cached = await redis.get(`idempotency:${idempotencyKey}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return c.json(body, status);
}
}
await next();
if (idempotencyKey && c.res.status < 500) {
const body = await c.res.clone().json();
await redis.setex(
`idempotency:${idempotencyKey}`,
86400,
JSON.stringify({ status: c.res.status, body })
);
}
});
Schema stability matters more when agents are consuming your API. Agents do not adapt to breaking changes the way human developers do. A field rename breaks the agent silently. If you must make a breaking change, version the endpoint and keep the old version alive until you have updated every agent that calls it.
Test your API with an actual agent. Run a small LangGraph or function-calling workflow against your staging API. The failure modes you will discover are different from what unit tests and postman collections reveal. Agents will find the ambiguous cases you thought were obvious.
Budget enforcement belongs at the API layer, not just the LLM layer. If an agent is allowed to call your search endpoint 10,000 times in a single run because your rate limiter resets every minute, you have a cost problem regardless of how well the agent is designed. Consider per-session call budgets tied to the X-Agent-Id.
The Design Shift
APIs designed for human developers can carry ambiguity because humans bring context. They read error messages, consult documentation, notice when something feels wrong, and make judgment calls.
Agents cannot do any of that. Every assumption you do not make explicit becomes a possible hallucination. Every ambiguous error becomes a retry loop. Every missing link becomes an extra tool call.
The shift is not technical. The patterns here are straightforward. The shift is in how you think about the contract. Write your API as if the caller is a capable but literal-minded process that will execute exactly what the response says and nothing more. That constraint, it turns out, is also good API design for humans.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.