Something has been bugging me about how we think about API traffic. We spend a lot of time building dashboards around DAU, WAU, MAU, all built on the assumption that a human is on the other end of each session. But increasingly, the thing calling your API is an agent. Not a person clicking buttons, but a program running an LLM that decided it needed data from your service.
This isn’t hypothetical. If you run a public API today, you probably already have agent traffic. Claude, GPT, Perplexity, and dozens of smaller tools are calling APIs on behalf of users through tool use, MCP servers, and function calling. The question isn’t whether agents are hitting your API. The question is whether you can tell them apart from human consumers, whether you can tell which agent is acting on behalf of which user, and whether you’re counting them correctly.
The good news is that the identity standards community has started catching up. There is real work happening at the IETF on how to represent agents in OAuth tokens, and a handful of vendors are shipping early implementations. The bad news is the gap between “where the standards are heading” and “what most APIs are doing today” is wide enough that you probably need to build something pragmatic in the meantime. This post is about both.
How Agents Identify Themselves (When They Bother)
There are four signals worth combining to figure out who, and what, is calling your API. They form a ladder from “easy to spoof” to “cryptographically asserted”: the User-Agent header, the OAuth client_id, the act actor claim, and traffic shape.
User-Agent Headers
The simplest signal is the User-Agent header. The well-behaved agents identify themselves. Cloudflare maintains a directory of known AI bot user-agents, and the list is growing fast:
ClaudeBot,Claude-SearchBot,Claude-User(Anthropic)GPTBot,ChatGPT-User,OAI-SearchBot(OpenAI)PerplexityBot,Perplexity-User(Perplexity)Google-CloudVertexBot(Google)Meta-ExternalAgent(Meta)
The problem is that plenty of agents don’t set a meaningful user-agent at all. Someone builds an agent with the Anthropic SDK, gives it a tool that calls your API, and the HTTP request comes through with python-requests/2.31.0 or whatever their HTTP library defaults to. You can’t distinguish that from a script, a test suite, or a human using a CLI.
OAuth Client IDs
This is where things get more useful. If your API uses OAuth (and it probably should), the client_id is the next signal up. Each integration that registers with your API gets a unique client ID, and you can attach metadata to it at registration time.
Here’s a minimal FastAPI setup that extracts and logs the client identity:
from fastapi import FastAPI, Depends, Request
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
import jwt
from datetime import datetime
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
class ClientIdentity(BaseModel):
client_id: str
client_type: str # "human", "agent", "service"
owner: str
scopes: list[str]
registered_at: datetime
# In-memory for illustration; this would be a database lookup
CLIENT_REGISTRY: dict[str, ClientIdentity] = {}
async def get_client_identity(token: str = Depends(oauth2_scheme)) -> ClientIdentity:
payload = jwt.decode(token, options={"verify_signature": True}, key=PUBLIC_KEY, algorithms=["RS256"])
client_id = payload["client_id"]
return CLIENT_REGISTRY[client_id]
@app.get("/api/data")
async def get_data(request: Request, client: ClientIdentity = Depends(get_client_identity)):
user_agent = request.headers.get("user-agent", "unknown")
log_request(
client_id=client.client_id,
client_type=client.client_type,
owner=client.owner,
user_agent=user_agent,
endpoint="/api/data",
)
return {"data": "..."}
The client_type field is the key piece. When someone registers an OAuth application to use your API, ask them what it is: a human integration, an agent, an internal service. This is self-reported and won’t be perfect, but it gives you a starting point. You can validate it later against actual traffic patterns.
The catch is that client_id identifies the OAuth client registration, not the runtime actor. One client registration (“Claude”) fronts millions of distinct agent invocations on behalf of millions of distinct users. If you only key off client_id, you can tell that “an agent” is calling you, but not which agent acting on behalf of which user. That gap is exactly what the next generation of OAuth specs is trying to close.
The Actor Claim: Who Is Actually Making This Request
The cleanest way I have seen to express “this token belongs to a user, but the request is being made by an agent acting on their behalf” is the act claim from RFC 8693 Token Exchange. The sub of the token is the authorizing principal (the human user). The act claim wraps a nested object identifying the actor exercising that authorization (the agent, or a tool the agent is calling).
Until recently the act claim was underspecified for the agent use case. Two new IETF drafts close that gap:
- OAuth Actor Profile for Delegation (Karl McGuinness, April 2026) extends
actwith aniss(the namespace context for the actor identifier) and asub_profile(entity-type classification:user,service,ai_agent,workload, etc.). It also defines how delegation chains are constructed via nestedactobjects, so a token can record “Alice authorized agent X, which sub-delegated to tool Y” all the way down. - OAuth Entity Profiles (Dean H. Saxe and colleagues) defines the
sub_profileandclient_profileclaims and the registry of allowed values that the actor-profile draft consumes.
Here is what a token looks like under that profile when a booking tool is calling your API on behalf of an AI travel assistant, which in turn is acting on behalf of Alice:
{
"iss": "https://tts.travel-provider.example",
"sub": "https://idp.enterprise.example/users/alice",
"sub_profile": "user",
"act": {
"sub": "https://tools.example.com/booking-tool",
"iss": "https://as.travel-provider.example",
"sub_profile": "service",
"act": {
"sub": "https://agents.example.com/travel-assistant",
"iss": "https://as.enterprise.example",
"sub_profile": "ai_agent"
}
}
}
Reading top-down: Alice is the subject. The immediate caller is the booking tool. Behind it sits the travel assistant agent that Alice originally authorized. Every entity has an explicit type and a namespace, so an RS that wants to enforce “AI agents can read but not write to this endpoint” has a deterministic claim to check rather than scraping a user-agent string.
A FastAPI dependency that consumes this looks roughly like:
from fastapi import Depends
class ResolvedCaller(BaseModel):
subject: str
subject_profile: str | None
immediate_actor: str | None
immediate_actor_profile: str | None
actor_chain: list[dict]
def _flatten_actor_chain(act: dict | None) -> list[dict]:
chain = []
while act:
chain.append({
"sub": act["sub"],
"iss": act.get("iss"),
"sub_profile": act.get("sub_profile"),
})
act = act.get("act")
return chain
async def resolve_caller(token: str = Depends(oauth2_scheme)) -> ResolvedCaller:
claims = jwt.decode(token, key=PUBLIC_KEY, algorithms=["RS256"])
chain = _flatten_actor_chain(claims.get("act"))
immediate = chain[0] if chain else None
return ResolvedCaller(
subject=claims["sub"],
subject_profile=claims.get("sub_profile"),
immediate_actor=immediate["sub"] if immediate else None,
immediate_actor_profile=immediate["sub_profile"] if immediate else None,
actor_chain=chain,
)
@app.get("/api/bookings")
async def list_bookings(caller: ResolvedCaller = Depends(resolve_caller)):
if caller.immediate_actor_profile == "ai_agent":
# Apply agent-specific policy: read-only, narrower scopes, more aggressive logging.
...
return await bookings.list(user=caller.subject)
The important thing about this shape is that it composes. You don’t lose the user (sub is still Alice), you don’t lose the agent (it’s in the chain), and you don’t have to guess from a client_id. Resource servers, audit pipelines, and metrics can all key off the same structured claim.
Adjacent and complementary work worth knowing about:
- Identity Assertion JWT Authorization Grant (ID-JAG) lets an enterprise IdP issue a portable JWT grant that downstream authorization servers can exchange for access tokens, which is how cross-domain agent flows are getting built today. The actor profile draft plugs directly into ID-JAG.
- Transaction Tokens address the internal service-mesh hop: every internal call is bound to the original request context, so you can still tell “this DB write was triggered by Alice’s agent” five services deep.
- MCP Authorization specifies how MCP servers act as OAuth resource servers and how MCP clients (often agents) authenticate. If you publish an MCP endpoint, you are publishing an OAuth-protected API whether you think of it that way or not.
- Cloudflare Web Bot Auth signs requests with HTTP Message Signatures (RFC 9421) so well-behaved bots and agents can prove their identity cryptographically rather than via a spoofable user-agent string. Useful as a complement to OAuth for unauthenticated traffic.
- Vendor implementations are arriving. Auth0/Okta, WorkOS, Stytch, and Microsoft Entra are all shipping agent-identity primitives that look a lot like (or that explicitly cite) the actor profile draft. Even if you don’t adopt the IETF draft directly, you will see its shape leak into the libraries you use.
I don’t think most APIs will be issuing fully-conformant actor-profile tokens this year. But the direction is now legible enough that you can design today’s client_type field with tomorrow’s sub_profile in mind, and so that the FastAPI middleware you build today will not need to be thrown away when your IdP catches up.
Traffic Patterns
Even without explicit identification, agents behave differently from humans. You can often identify them by how they call your API:
from collections import defaultdict
from datetime import datetime, timedelta
class TrafficAnalyzer:
def __init__(self):
self.request_log: dict[str, list[datetime]] = defaultdict(list)
def record(self, client_id: str):
now = datetime.now()
self.request_log[client_id].append(now)
# Keep a rolling window
cutoff = now - timedelta(hours=1)
self.request_log[client_id] = [
t for t in self.request_log[client_id] if t > cutoff
]
def classify(self, client_id: str) -> str:
timestamps = self.request_log[client_id]
if len(timestamps) < 2:
return "unknown"
intervals = [
(timestamps[i] - timestamps[i - 1]).total_seconds()
for i in range(1, len(timestamps))
]
avg_interval = sum(intervals) / len(intervals)
variance = sum((i - avg_interval) ** 2 for i in intervals) / len(intervals)
# Agents tend to have very consistent intervals or burst patterns
if variance < 0.5 and avg_interval < 2.0:
return "likely_agent"
# Rapid bursts followed by silence
if avg_interval < 0.5 and len(timestamps) > 20:
return "likely_agent"
return "likely_human"
Agents tend to make requests in tight bursts (tool call, process, tool call, process) or at very regular intervals. Humans are messier. They pause, they click around, they get distracted. This heuristic is rough but it’s a useful secondary signal alongside client IDs and user-agents.
Putting It Together: Middleware That Tags Everything
In practice you want to combine all four signals — actor claim, registered client type, user-agent, and traffic shape — into a single middleware that tags every request. The actor claim is the most authoritative when present; the rest are fallbacks for the long tail of traffic that hasn’t caught up to the spec yet.
from fastapi import FastAPI, Request, Depends
from enum import Enum
class ConsumerType(str, Enum):
HUMAN = "human"
AGENT = "agent"
UNKNOWN = "unknown"
KNOWN_AGENT_PATTERNS = [
"claudebot", "claude-user", "claude-searchbot",
"gptbot", "chatgpt-user", "oai-searchbot",
"perplexitybot", "perplexity-user",
"google-cloudvertexbot", "meta-externalagent",
]
AGENT_ACTOR_PROFILES = {"ai_agent", "agent"}
HUMAN_SUBJECT_PROFILES = {"user", "person"}
def classify_consumer(
immediate_actor_profile: str | None,
subject_profile: str | None,
client_type: str | None,
user_agent: str,
traffic_classification: str,
) -> ConsumerType:
# The actor profile claim is the most authoritative signal when present.
if immediate_actor_profile and any(
p in AGENT_ACTOR_PROFILES for p in immediate_actor_profile.split()
):
return ConsumerType.AGENT
if (
immediate_actor_profile is None
and subject_profile in HUMAN_SUBJECT_PROFILES
):
return ConsumerType.HUMAN
if client_type == "agent":
return ConsumerType.AGENT
if client_type == "human":
return ConsumerType.HUMAN
ua_lower = user_agent.lower()
if any(pattern in ua_lower for pattern in KNOWN_AGENT_PATTERNS):
return ConsumerType.AGENT
if traffic_classification == "likely_agent":
return ConsumerType.AGENT
return ConsumerType.UNKNOWN
@app.middleware("http")
async def tag_consumer(request: Request, call_next):
client = await get_client_identity_optional(request)
caller = await resolve_caller_optional(request)
user_agent = request.headers.get("user-agent", "")
traffic_class = analyzer.classify(client.client_id if client else "anonymous")
consumer_type = classify_consumer(
immediate_actor_profile=caller.immediate_actor_profile if caller else None,
subject_profile=caller.subject_profile if caller else None,
client_type=client.client_type if client else None,
user_agent=user_agent,
traffic_classification=traffic_class,
)
request.state.consumer_type = consumer_type
request.state.client_id = client.client_id if client else "anonymous"
request.state.subject = caller.subject if caller else None
request.state.immediate_actor = caller.immediate_actor if caller else None
response = await call_next(request)
emit_metric("api.request", tags={
"consumer_type": consumer_type.value,
"client_id": request.state.client_id,
"subject": request.state.subject or "anonymous",
"actor": request.state.immediate_actor or "self",
"endpoint": request.url.path,
})
return response
Now every request gets tagged with the user (sub), the immediate actor, and a classification. Your metrics pipeline can answer “how many distinct agents called on Alice’s behalf this week?” — a question the unaugmented OAuth model cannot answer at all.
Agent Traffic Is DAU Traffic
Here’s where I think most teams get this wrong. We tend to treat bot and agent traffic as noise to be filtered out of our metrics. We strip it from analytics, exclude it from DAU counts, and generally pretend it doesn’t exist.
But if an agent is calling your API on behalf of a paying customer, that’s real usage. That’s a user getting value from your product, just through a different interface. When someone sets up a Claude MCP server that queries your API, or builds a GPT that uses your data, or wires up a LangChain agent with your service as a tool, those are active users of your platform.
I think we need to move toward tracking both:
from dataclasses import dataclass, field
from datetime import date
from collections import defaultdict
@dataclass
class DailyActiveMetrics:
date: date
human_dau: set[str] = field(default_factory=set)
agent_dau: set[str] = field(default_factory=set)
@property
def total_dau(self) -> int:
return len(self.human_dau | self.agent_dau)
@property
def agent_ratio(self) -> float:
if self.total_dau == 0:
return 0.0
return len(self.agent_dau) / self.total_dau
metrics_by_day: dict[date, DailyActiveMetrics] = defaultdict(lambda: DailyActiveMetrics(date=date.today()))
def record_active_user(client_id: str, consumer_type: ConsumerType):
today = date.today()
day_metrics = metrics_by_day[today]
if consumer_type == ConsumerType.AGENT:
day_metrics.agent_dau.add(client_id)
else:
day_metrics.human_dau.add(client_id)
The agent_ratio metric is the one I’d watch closely. If it’s trending up, that tells you something important about how your product is being consumed. Maybe your API is more valuable as an agent tool than as a direct integration. Maybe you should be optimizing your API responses for LLM consumption (structured data, clear error messages, deterministic formats) rather than assuming a human developer is reading the JSON.
Tony Beltramelli wrote about this shift earlier this year, arguing that metrics like DAU are becoming “dangerously misleading” when your fastest-growing user segment is non-human. I agree. The traditional metric still matters, but without a breakdown by consumer type, you’re flying blind.
What This Means for API Design
Once you’re tracking agent consumers separately, a few design implications follow:
Authorization should consider the (subject, actor) pair, not just the subject. Alice might be allowed to delete a record when she clicks the button, but you might not want her agent to delete records autonomously without a human confirmation. With the actor profile shape, that policy is expressible: “deny DELETE when act.sub_profile == ai_agent and the scope does not include agent:write.” Without it, you cannot tell the difference between Alice and Alice’s agent.
Rate limiting should be per-(subject, actor), not per-user. An agent might make 50 API calls in a single user interaction. That’s not abuse, that’s an agentic loop doing its job. If your rate limits are tuned for human click patterns, you’ll throttle legitimate agent usage. Conversely, you may want a separate, looser bucket for the agent and a stricter one for the user, since one human can fan out to many agent workers.
Error responses need to be LLM-readable. An agent parsing your 400 response doesn’t need a friendly HTML error page. It needs a structured JSON body with a clear error code and a description that an LLM can reason about. Something like:
{
"error": "invalid_date_range",
"message": "The 'start' date must be before the 'end' date.",
"field": "start",
"hint": "Swap the start and end values, or provide a valid date range."
}
Documentation should be machine-consumable. OpenAPI specs aren’t just for generating client libraries anymore. They’re the tool description that an LLM reads to figure out how to call your API. If your spec is incomplete or inaccurate, agents will struggle.
Pricing might need to change. If an agent makes 100x the API calls of a human user but generates equivalent business value, your per-request pricing model might not be right. Some teams are moving toward outcome-based or tier-based pricing that accounts for agent consumption patterns.
Start Simple
You don’t need to build all of this at once. If I were adding agent identification to an existing FastAPI service tomorrow, I’d start with three things:
- Add a
client_typefield to your OAuth registration flow. Even a simple dropdown (“human integration”, “AI agent”, “internal service”) gives you a signal you didn’t have before, and it maps directly to thesub_profilevalues the IETF drafts are landing on. - Log the
User-Agentalongside theclient_idon every request. You can analyze it later to see what’s already calling you. - If your tokens are JWTs, parse the
actclaim if it’s present and putsubjectandimmediate_actorinto your request log. Even if no one is sending you actor-profile-conformant tokens yet, the day someone does, you will already be capturing it.
Everything else — the traffic analysis, the consumer-type middleware, the split DAU metrics — builds on top of those data points. The most important step is just starting to ask the question: who is actually using this API, and on whose behalf?
Further Reading
Standards and emerging specs
- OAuth Actor Profile for Delegation — Karl McGuinness’s IETF draft defining the
actprofile,sub_profile, and delegation chains. The primary reference for the actor-claim approach in this post. - OAuth Entity Profiles — Defines
sub_profileandclient_profileand the registry of entity types. - Identity Assertion JWT Authorization Grant (ID-JAG) — How enterprise IdPs issue portable grants that downstream authorization servers can exchange.
- OAuth 2.0 Token Exchange (RFC 8693) — Where the
actclaim originated. - Transaction Tokens — Carrying request context across internal service hops.
- MCP Authorization — How MCP servers act as OAuth resource servers.
- HTTP Message Signatures (RFC 9421) and Cloudflare’s Web Bot Auth — Cryptographic bot/agent identity outside of OAuth.
Product and operational perspective
- Your Users Aren’t Human Anymore — Tony Beltramelli on rethinking product metrics for agent consumers.
- AI Agents Are the New API Users — Practical guide to making APIs agent-ready.
- OAuth vs API Keys vs mTLS for AI Agents — Security comparison for agent authentication patterns.
- Cloudflare AI Bot Reference — Directory of known AI bot user-agents.
- AWS WAF AI Activity Dashboard — AWS tooling for visibility into AI agent traffic.