Designing APIs for AI Agent Consumers — Not Humans
Advertisement
Introduction
AI agents are a fundamentally different kind of API consumer. They do not read your documentation. They cannot tolerate ambiguous error messages or inconsistent field names. They retry aggressively, call at scale, and depend on machine-readable contracts. Designing APIs for agents means rethinking every assumption you made for human clients.
Key Differences: AI Agents vs Human Clients
When a human encounters an ambiguous API response, they read the docs or ask Slack. When an agent encounters ambiguity, it retries randomly, gets stuck in a loop, or silently does the wrong thing.
Agents need determinism. If an endpoint sometimes returns { "status": "processing" } and sometimes { "state": "queued" }, an agent cannot program against it. Field names, status enumerations, and error codes must be completely stable.
Agents need bulk operations. Humans submit one form at a time. Agents submit 10,000 records. If your API lacks batch endpoints, agents will hammer your single-record endpoints with parallel requests, overwhelming your infrastructure.
Agents need structured errors. A human can parse "Something went wrong" and call support. An agent needs a machine-readable error code, a retry hint, and a clear description of what was wrong.
// Bad API: error a human might parse
{ "error": "failed" }
// Good API: error an agent can act on
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Token quota exceeded for this billing period",
"retryAfter": 60,
"details": {
"tokenLimit": 1000000,
"tokensUsed": 1000000,
"resetAt": "2026-03-18T14:30:00Z"
},
"requestId": "req_abc123"
}
}Idempotency Keys Are Non-Negotiable
Agents retry. Networks are unreliable. Without idempotency, retries create duplicate records, double charges, and corrupted state.
Every state-changing endpoint must require an Idempotency-Key header. The same key must always produce the same result, even if the request is replayed hours later.
const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient();
app.post('/api/process', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({
error: {
code: 'MISSING_IDEMPOTENCY_KEY',
message: 'Idempotency-Key header is required for all POST requests'
}
});
}
// Check cache first
const cached = await client.get(`idempotency:${idempotencyKey}`);
if (cached) {
return res.json(JSON.parse(cached));
}
// Process the request
const result = await processRequest(req.body);
// Cache the response with TTL
await client.setEx(
`idempotency:${idempotencyKey}`,
3600,
JSON.stringify(result)
);
res.json(result);
});Cursor-Based Pagination for Stable Traversal
Agents page through large datasets systematically. Offset pagination breaks when records are inserted or deleted between pages — the agent skips or duplicates rows. Cursor-based pagination is stable regardless of concurrent data changes.
app.get('/api/users', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 1000);
const after = req.query.after || null;
let users;
if (after) {
users = await db.users
.where('id', '>', after)
.orderBy('id', 'asc')
.limit(limit + 1);
} else {
users = await db.users
.orderBy('id', 'asc')
.limit(limit + 1);
}
const hasMore = users.length > limit;
const data = users.slice(0, limit);
const nextCursor = hasMore ? data[data.length - 1].id : null;
res.json({
data,
pagination: {
cursor: nextCursor,
hasMore,
totalCount: await db.users.count()
}
});
});Webhook and Polling for Async Operations
Agents cannot long-poll. They need either webhooks for push notification or a status endpoint for polling. Provide both — agents may not have a public webhook endpoint.
// Agent initiates async job
// POST /api/jobs
// { "type": "process_dataset", "datasetId": "ds_123", "webhookUrl": "..." }
// Response:
// { "jobId": "job_abc123", "status": "queued" }
// Status polling endpoint
app.get('/api/jobs/:jobId', async (req, res) => {
const job = await db.jobs.findById(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json({
jobId: job.id,
status: job.status, // queued | processing | completed | failed
progress: {
current: job.progressCurrent,
total: job.progressTotal
},
result: job.status === 'completed' ? job.result : null,
error: job.status === 'failed' ? job.error : null,
createdAt: job.createdAt,
completedAt: job.completedAt
});
});OpenAPI Spec as the Agent Contract
Agents read OpenAPI specs to understand your API. Your spec must be complete, accurate, and machine-parseable. Every endpoint needs an operationId. Every response shape must be fully described. Every enum value must be listed.
paths:
/api/process:
post:
operationId: processRequest
summary: Process a request asynchronously
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
data:
type: string
required: [data]
responses:
'202':
description: Request accepted
content:
application/json:
schema:
$ref: '#/components/schemas/JobCreated'
'429':
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integerCost-Aware Rate Limiting
Human users trigger one request at a time. An agent may trigger 10,000. Rate limiting based only on request count is insufficient — a single agent request may consume vastly different resources depending on input size.
Rate limit agents based on token cost or compute units, not raw request count. Return clear headers so agents can implement backoff automatically.
class TokenBucketLimiter {
async check(agentId, cost) {
const key = `bucket:${agentId}`;
const now = Date.now();
let bucket = await redis.get(key);
if (!bucket) {
bucket = { capacity: 1000000, refillRate: 100000, lastRefill: now };
}
const elapsed = (now - bucket.lastRefill) / 60000;
bucket.capacity = Math.min(
bucket.capacity + bucket.refillRate * elapsed,
1000000
);
bucket.lastRefill = now;
if (bucket.capacity < cost) {
const waitMinutes = (cost - bucket.capacity) / bucket.refillRate;
return { allowed: false, retryAfterSeconds: Math.ceil(waitMinutes * 60) };
}
bucket.capacity -= cost;
await redis.set(key, JSON.stringify(bucket), 'EX', 3600);
return { allowed: true };
}
}Version Stability for Long-Running Agents
Agents depend on field names and types. Changing your API breaks deployed agents — sometimes agents running autonomously that nobody actively monitors.
Use date-versioned APIs and support old versions for at least six months after announcing deprecation. Agents need a stable surface to build on.
// Version in header — lets URL stay clean
// GET /api/users
// API-Version: 2026-03-01
app.get('/api/users', (req, res) => {
const version = req.headers['api-version'] || '2026-01-01';
if (version < '2026-03-01') {
// Old shape
return res.json({
data: users.map(u => ({
userId: u.id,
firstName: u.first_name,
lastName: u.last_name
}))
});
}
// New shape
res.json({
data: users.map(u => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
email: u.email
}))
});
});Key Takeaways
- Every state-changing endpoint must require an
Idempotency-Keyheader — agents retry aggressively and duplicates cause data corruption - Error responses must include a machine-readable
code, a human-readablemessage, and arequestIdfor support tracing - Use cursor-based pagination — offset pagination produces skipped or duplicated records when data changes between pages
- Provide both webhook callbacks and status-polling endpoints for async operations — not all agents can receive webhooks
- Your OpenAPI spec is the agent contract: every field, enum value, and response shape must be fully documented
- Rate limit by cost or token consumption, not just request count — one agent request can cost 1,000x another
- Support API versions for at least six months after deprecation announcement — agents run autonomously and may not be actively monitored
Advertisement