Why start with actor context instead of rules?
Most teams that notice AI agents in their traffic reach for a rule first. Block a user agent, add a challenge, tighten a rate limit. The problem is that a rule written before you understand the traffic tends to hit the wrong people. Legitimate assistants get blocked, real users get challenged, and the automation you actually worried about keeps coming because it never identified itself in the first place.
Actor context is the step before rules. For every request, you record what your application can observe about who or what is acting: an interactive person, a person working with an AI assistant, an autonomous agent, or something you cannot yet tell apart. You record the evidence behind that view, and you keep it next to the outcome of the request. After a week or two of this, you have something no rule template can give you: a map of your own traffic. If you are still deciding whether agents reach your site at all, start with how to tell if website traffic comes from AI agents.
This guide builds that layer in a Node.js application using plain middleware. It works with Express, and the same ideas carry over to Fastify, Koa, Hono or a Next.js route handler.
What you will build
By the end you will have:
- A middleware that extracts actor evidence from each request.
- A small, explicit classification with a stated basis for every result.
- Structured observation logs that never change how the request is handled.
- A way to attach the result to the request so later handlers can read it.
Nothing here blocks a request. That is deliberate.
Step 1: What evidence can your server actually see?
A server sees less than people assume. Before writing code, list the signals available at your edge:
- Request signatures. Some automated clients sign requests using HTTP Message Signatures (RFC 9421), sending
Signature-InputandSignatureheaders, often alongside aSignature-Agentheader that points to the operator's published keys, as described in the IETF Web Bot Auth protocol draft. - Declared identity. Clients may name themselves in the
User-Agentheader, in aFromheader, or through protocol metadata such as an MCP client name. MCP clients report a self-chosenclientInfoname in the initialize request of the 2025-11-25 revision, and the 2026-07-28 revision carries client identity as per-request metadata. These are declarations, and anyone can copy them. - Authentication context. Whether the request carries a session cookie, a bearer token or an API key, and which kind of credential it is.
- Protocol shape. Accept headers, whether the client requests HTML or JSON, whether it loads page assets, and how it handles redirects and cookies.
- Application declarations. Your own product may know things the network cannot, such as a user choosing an "AI assisted" option, or a request coming from your built-in assistant feature.
Each signal has a different strength. A validated signature is checkable. A user agent string is a claim. A missing cookie is just missing. Keeping these separate is the whole point of the exercise.
Step 2: Model the result explicitly
Define the shape of an actor result before you write the detection logic. A small, typed model keeps everyone honest about what a classification means. The four classes used here are explained in Human, AI-assisted or agent: a practical taxonomy for application traffic.
export type ActorClass = "human" | "human_ai" | "agent" | "unknown";
export type EvidenceBasis = "verified" | "declared" | "inferred" | "unknown";
export interface ActorEvidence {
signaturePresent: boolean;
signatureAgent?: string;
declaredClient?: string;
credentialType: "session" | "api_key" | "bearer" | "none";
acceptsHtml: boolean;
appDeclaredAssist: boolean;
}
export interface ActorResult {
actor: ActorClass;
basis: EvidenceBasis;
reasons: string[];
evidence: ActorEvidence;
observedAt: string;
}Two fields matter most. actor is the class. basis says why you believe it. An agent result based on a validated signature and an agent result based on a user agent string are both "agent", but they deserve very different treatment later, so you never collapse them.
Step 3: Extract evidence in middleware
The extraction step reads headers and request state. It does not decide anything yet.
import type { Request, Response, NextFunction } from "express";
const KNOWN_AGENT_TOKENS = ["bot", "crawler", "spider", "agent", "gpt", "claude"];
export function extractEvidence(req: Request): ActorEvidence {
const ua = (req.get("user-agent") || "").toLowerCase();
const auth = req.get("authorization") || "";
const hasSession = Boolean(req.cookies?.session);
let credentialType: ActorEvidence["credentialType"] = "none";
if (hasSession) credentialType = "session";
else if (auth.toLowerCase().startsWith("bearer ")) credentialType = "bearer";
else if (req.get("x-api-key")) credentialType = "api_key";
const declaredToken = KNOWN_AGENT_TOKENS.find((t) => ua.includes(t));
return {
signaturePresent: Boolean(req.get("signature") && req.get("signature-input")),
signatureAgent: req.get("signature-agent") || undefined,
declaredClient: declaredToken ? ua.slice(0, 200) : undefined,
credentialType,
acceptsHtml: (req.get("accept") || "").includes("text/html"),
appDeclaredAssist: req.get("x-app-assist") === "1",
};
}A few notes on this code:
signaturePresentonly records that signature headers exist. Validation is a separate step, covered in the guide on verifying signed agent requests.- The user agent token list is intentionally simple. It produces a declaration, never a verification.
x-app-assiststands in for whatever your own frontend sends when a user works through your built-in AI feature. Replace it with your real mechanism.
Step 4: Classify with a stated basis
Now turn evidence into a result. Keep the rules short, ordered from strongest evidence to weakest, and always fall through to Unknown.
export async function classify(
evidence: ActorEvidence,
verifySignature: () => Promise<boolean>,
): Promise<ActorResult> {
const reasons: string[] = [];
const observedAt = new Date().toISOString();
if (evidence.signaturePresent && (await verifySignature())) {
reasons.push("request signature validated");
return { actor: "agent", basis: "verified", reasons, evidence, observedAt };
}
if (evidence.appDeclaredAssist) {
reasons.push("application reported its built-in assistant");
return { actor: "human_ai", basis: "declared", reasons, evidence, observedAt };
}
if (evidence.declaredClient) {
reasons.push("client declared itself as automated");
return { actor: "agent", basis: "declared", reasons, evidence, observedAt };
}
if (evidence.credentialType === "session" && evidence.acceptsHtml) {
reasons.push("interactive browser session");
return { actor: "human", basis: "inferred", reasons, evidence, observedAt };
}
reasons.push("insufficient evidence");
return { actor: "unknown", basis: "unknown", reasons, evidence, observedAt };
}Notice what this does not do. It never upgrades a declaration to a verification. It never assigns Human to a request that has no interactive signal. When it cannot tell, it says Unknown. Unknown is a valid, useful answer, and it tells you exactly where your visibility ends. We make the full case in why Unknown is a valid result for AI traffic.
Step 5: Wire it in observation mode
Mount the middleware early, attach the result to the request, and log it. Do not branch on it.
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
app.use(async (req, res, next) => {
try {
const evidence = extractEvidence(req);
const result = await classify(evidence, async () => false);
res.locals.actor = result;
res.on("finish", () => {
logActor({
route: req.route?.path ?? req.path,
method: req.method,
status: res.statusCode,
actor: result.actor,
basis: result.basis,
reasons: result.reasons,
});
});
} catch {
res.locals.actor = undefined;
}
next();
});Three details make this safe:
- The
tryblock guarantees that a failure in detection never affects the response. Observation must fail open. - Logging happens on
finish, so you capture the real status code and can later compare classifications with outcomes. verifySignatureis stubbed tofalseuntil you implement real validation. That keeps signed requests in the declared or unknown buckets rather than overstating them.
Step 6: Log for analysis, not just debugging
Write logs that you can aggregate. A structured line per request is enough.
function logActor(entry: Record<string, unknown>) {
process.stdout.write(JSON.stringify({ type: "actor_observation", ...entry }) + "\n");
}Keep personal data out of these logs. You need the route, the class, the basis, the reasons and the outcome. You do not need full IP addresses, raw cookies or request bodies to understand your traffic mix. If you keep any identifier for correlation, hash it with a rotating salt and set a short retention period.
Step 7: Read the data before you act
Run in observation mode long enough to see normal weekly patterns. Then answer a few questions:
- Which routes attract agents? Documentation, catalog and search endpoints usually do. Account and checkout flows usually should not.
- What share of agent results are verified versus declared? That ratio tells you how much you can safely rely on identity when you write policies.
- Where is Unknown concentrated? A high Unknown rate on a route often means you are missing a signal there, for example a page that never runs your frontend code.
- Do outcomes differ by class? Compare error rates, conversion steps or abuse reports across classes. This is where real policy ideas come from.
The answers will often surprise you. Teams regularly find that a large part of their "bot problem" is actually legitimate automation that would happily use a structured endpoint if one existed.
Step 8: Expose the result to the rest of your app
Once the data is flowing, let handlers read res.locals.actor for non-enforcing uses: analytics tags, choosing a response format, or recording that a support ticket came from an AI-assisted session. These uses improve the experience without risking anyone being locked out.
app.get("/docs/:page", (req, res) => {
const actor = res.locals.actor as ActorResult | undefined;
if (actor?.actor === "agent" && req.accepts("text/markdown")) {
return res.type("text/markdown").send(renderMarkdown(req.params.page));
}
return res.render("doc", { page: req.params.page });
});Enforcement, such as challenges, rate limits or step-up checks, comes later, after you have measured false positives. The guide on rolling out actor-aware policies covers that process.
What mistakes should you avoid?
- Treating the user agent as identity. It is the easiest header to change. Record it as a declaration. Even AI operators say so: Anthropic's guidance for its hosted browser agent notes that anyone can send the same User-Agent header and recommends checking its published IP addresses before treating the traffic as genuine.
- Defaulting to Human. If your code says Human whenever it cannot find bot signals, your Human numbers will be inflated and your policies will trust the wrong traffic.
- Mixing class and basis. Always store both. A dashboard that shows only "Agent" hides the difference between a verified operator and a copied string.
- Blocking during observation. Even a temporary block invalidates your measurements, because blocked clients change their behavior.
Where Proof of Actor fits
The middleware above is a solid starting point, and it shows the core model: evidence first, an explicit basis for every result, Unknown kept visible, and decisions left to your application. Proof of Actor builds on the same model with deeper signal coverage across server, edge, browser and MCP contexts, and analytics that let you Detect, Understand and Route with confidence.
If you want help taking this further on a production app, apply for early access. To see the full decision flow, read How Proof of Actor works.
Sources
- RFC 9421: HTTP Message Signatures: the standard behind signed requests
- draft-ietf-webbotauth-httpsig-protocol: IETF Web Bot Auth draft defining the
Signature-Agentheader (work in progress as of November 2026) - RFC 9110: HTTP Semantics, User-Agent and From: self-reported client identification headers
- MCP specification 2025-11-25: Lifecycle: the
initializerequest andclientInfo - MCP specification 2026-07-28: Versioning and Compatibility: per-request version, identity and capability metadata
- Anthropic: how Anthropic-hosted browsers identify themselves: user agent token, published IP addresses and the warning that the header can be copied